From 2148ecb58f57f8027149ac7e5562e59ea905a4fe Mon Sep 17 00:00:00 2001 From: Akhil G Krishnan Date: Mon, 18 Apr 2022 20:44:25 +0530 Subject: [PATCH 01/30] Bump version to 0.1.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 58b3ae1813..8ddb8bb943 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@saeloun/miru-web", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.16.5", "@babel/preset-react": "^7.16.5", From 7aa86c8099bf1f84cb5801dc09f5e4d19a5541b3 Mon Sep 17 00:00:00 2001 From: Ajinkya Deshmukh Date: Tue, 19 Apr 2022 06:17:57 +0530 Subject: [PATCH 02/30] Client details page (#282) * client list page updated * clean up * folder structure updated * client details page * client side validation * ui lint fixes * lint fixes * pagy updated * client updates * ruby tests updated * commented index_spec.rb in spec/requests/clients folder Co-authored-by: Aniket Kaushik --- .../internal_api/v1/clients_controller.rb | 7 + app/javascript/src/apis/client.ts | 0 app/javascript/src/apis/clients.ts | 6 +- app/javascript/src/common/Table/index.tsx | 16 +- .../src/components/Clients/Details/Header.tsx | 84 +++++++ .../src/components/Clients/Details/index.tsx | 170 +++++++++++++ .../src/components/Clients/List/Header.tsx | 34 +++ .../src/components/Clients/List/index.tsx | 32 ++- .../components/Clients/Modals/EditClient.tsx | 227 ++++++++---------- .../components/Clients/Modals/NewClient.tsx | 131 ++++++++++ .../src/components/Clients/RouteConfig.tsx | 23 ++ app/javascript/src/mapper/client.mapper.ts | 28 ++- app/views/clients/index.html.erb | 68 +----- .../v1/clients/_client.json.jbuilder | 3 + .../v1/clients/create.json.jbuilder | 3 + config/routes.rb | 5 +- config/routes/internal_api.rb | 2 +- package.json | 4 +- spec/requests/clients/create_spec.rb | 138 ----------- spec/requests/clients/index_spec.rb | 103 ++++---- .../internal_api/v1/clients/create_spec.rb | 59 +++++ yarn.lock | 72 +++++- 22 files changed, 813 insertions(+), 402 deletions(-) delete mode 100644 app/javascript/src/apis/client.ts create mode 100644 app/javascript/src/components/Clients/Details/Header.tsx create mode 100644 app/javascript/src/components/Clients/Details/index.tsx create mode 100644 app/javascript/src/components/Clients/List/Header.tsx create mode 100644 app/javascript/src/components/Clients/Modals/NewClient.tsx create mode 100644 app/javascript/src/components/Clients/RouteConfig.tsx create mode 100644 app/views/internal_api/v1/clients/_client.json.jbuilder create mode 100644 app/views/internal_api/v1/clients/create.json.jbuilder delete mode 100644 spec/requests/clients/create_spec.rb create mode 100644 spec/requests/internal_api/v1/clients/create_spec.rb diff --git a/app/controllers/internal_api/v1/clients_controller.rb b/app/controllers/internal_api/v1/clients_controller.rb index 8f40ea12bd..7b3bd1cb1f 100644 --- a/app/controllers/internal_api/v1/clients_controller.rb +++ b/app/controllers/internal_api/v1/clients_controller.rb @@ -10,6 +10,13 @@ def index render json: { client_details:, total_minutes: }, status: :ok end + def create + authorize Client + render :create, locals: { + client: Client.create!(client_params) + } + end + def show authorize client project_details = client.project_details(params[:time_frame]) diff --git a/app/javascript/src/apis/client.ts b/app/javascript/src/apis/client.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/javascript/src/apis/clients.ts b/app/javascript/src/apis/clients.ts index 9e1a8afe19..299156bf19 100644 --- a/app/javascript/src/apis/clients.ts +++ b/app/javascript/src/apis/clients.ts @@ -4,10 +4,14 @@ const path = "/clients"; const get = async (queryParam) => axios.get(`${path}${queryParam}`); +const create = async (payload) => axios.post(`${path}`, payload); + +const show = async (id, queryParam) => axios.get(`${path}/${id}${queryParam}`); + const update = async (id, payload) => axios.patch(`${path}/${id}`, payload); const destroy = async id => axios.delete(`${path}/${id}`); -const clients = { update, destroy, get }; +const clients = { update, destroy, get, show, create }; export default clients; diff --git a/app/javascript/src/common/Table/index.tsx b/app/javascript/src/common/Table/index.tsx index 4e631d4326..7acaabb7fa 100644 --- a/app/javascript/src/common/Table/index.tsx +++ b/app/javascript/src/common/Table/index.tsx @@ -55,7 +55,7 @@ const Table = ({ hasRowIcons=false, handleDeleteClick = (id) => {}, // eslint-disable-line handleEditClick = (id) => {}, // eslint-disable-line - rowOnClick = () => {} // eslint-disable-line + rowOnClick = (id) => {} // eslint-disable-line }) => { const data = React.useMemo(() => tableRowArray, [tableRowArray]); @@ -96,15 +96,23 @@ const Table = ({ const cssClassLastRow = rows.length - 1 !== index ? "border-b": ""; const cssClassRowHover = hasRowIcons ? "hoverIcon" : ""; return ( - + rowOnClick(row.original.rowId)} className={`${cssClassLastRow} ${cssClassRowHover}`}> {row.cells.map(cell => {cell.render("Cell")})} {hasRowIcons &&
- -
diff --git a/app/javascript/src/components/Clients/Details/Header.tsx b/app/javascript/src/components/Clients/Details/Header.tsx new file mode 100644 index 0000000000..d3c9be142e --- /dev/null +++ b/app/javascript/src/components/Clients/Details/Header.tsx @@ -0,0 +1,84 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowLeft, DotsThreeVertical, Receipt, Pencil, CaretDown, Trash } from "phosphor-react"; + +const Header = ({ clientDetails }) => { + + const [isHeaderMenuVisible, setHeaderMenuVisibility] = useState(false); + const [isClientOpen, toggleClientDetails] = useState(false); + + const navigate = useNavigate(); + + const handleClientDetails = () => { + toggleClientDetails(!isClientOpen); + }; + + const handleMenuVisibility = () => { + setHeaderMenuVisibility(!isHeaderMenuVisible); + }; + + const handleBackButtonClick = () => { + navigate("/clients"); + }; + + const menuBackground = isHeaderMenuVisible ? "bg-miru-gray-1000" : ""; + return ( +
+
+
+ +

+ {clientDetails.name} +

+ +
+
+ + { isHeaderMenuVisible &&
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
} +
+
+ {isClientOpen &&
+
+
Email ID(s)
+

{clientDetails.email}

+
+
+
Address
+

--

+
+
+
Phone number
+

--

+
+
+ } +
+ ); +}; + +export default Header; diff --git a/app/javascript/src/components/Clients/Details/index.tsx b/app/javascript/src/components/Clients/Details/index.tsx new file mode 100644 index 0000000000..78f4028190 --- /dev/null +++ b/app/javascript/src/components/Clients/Details/index.tsx @@ -0,0 +1,170 @@ +import React, { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { ToastContainer } from "react-toastify"; +import { setAuthHeaders, registerIntercepts } from "apis/axios"; +import clients from "apis/clients"; + +import AmountBoxContainer from "common/AmountBox"; +import ChartBar from "common/ChartBar"; +import Table from "common/Table"; + +import Header from "./Header"; +import { unmapClientDetails } from "../../../mapper/client.mapper"; +import DeleteClient from "../Modals/DeleteClient"; +import EditClient from "../Modals/EditClient"; + +const getTableData = (clients) => { + if (clients) { + return clients.map((client) => { + const hours = client.minutes/60; + return { + col1:
{client.name}
, + col2:
{client.team.map(member => {member}, )}
, + col3:
{hours}
, + rowId: client.id + }; + }); + } + return [{}]; +}; + +const ClientList = ({ isAdminUser }) => { + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [clientToEdit, setClientToEdit] = useState({}); + const [clientToDelete, setClientToDelete] = useState({}); + const [clientData, setClientData] = useState(); + const [totalMinutes, setTotalMinutes] = useState(null); + const [clientDetails, setClientDetails] = useState({}); + + const params = useParams(); + + const handleEditClick = (id) => { + setShowEditDialog(true); + const editSelection = clientData.find(client => client.id === id); + setClientToEdit(editSelection); + }; + + const handleDeleteClick = (id) => { + setShowDeleteDialog(true); + const editSelection = clientData.find(client => client.id === id); + setClientToDelete(editSelection); + }; + + const handleSelectChange = (event) => { + clients.show(params.clientId,`?time_frame=${event.target.value}`) + .then((res) => { + const sanitized = unmapClientDetails(res); + setClientData(sanitized.projectDetails); + setClientDetails(sanitized.clientDetails); + setTotalMinutes(sanitized.totalMinutes); + }); + }; + + useEffect(() => { + setAuthHeaders(); + registerIntercepts(); + clients.show(params.clientId, "?time_frame=week") + .then((res) => { + const sanitized = unmapClientDetails(res); + setClientDetails(sanitized.clientDetails); + setClientData(sanitized.projectDetails); + setTotalMinutes(sanitized.totalMinutes); + }); + }, []); + + const tableHeader = [ + { + Header: "PROJECT", + accessor: "col1", // accessor is the "key" in the data + cssClass: "" + }, + { + Header: "TEAM", + accessor: "col2", + cssClass: "" + }, + { + Header: "HOURS LOGGED", + accessor: "col3", + cssClass: "text-right" // accessor is the "key" in the data + } + ]; + + const amountBox = [{ + title: "OVERDUE", + amount: "$35.5k" + }, + { + title: "OUTSTANDING", + amount: "$24.3k" + }]; + + const tableData = getTableData(clientData); + + return ( + <> + +
+
+ { isAdminUser &&
+
+ +
+ {clientData && } + +
+ } +
+
+
+
+ { clientData && } + + + + + + {showEditDialog && + + } + {showDeleteDialog && ( + + )} + + ); +}; + +export default ClientList; diff --git a/app/javascript/src/components/Clients/List/Header.tsx b/app/javascript/src/components/Clients/List/Header.tsx new file mode 100644 index 0000000000..9d386aa331 --- /dev/null +++ b/app/javascript/src/components/Clients/List/Header.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { MagnifyingGlass, Plus } from "phosphor-react"; + +const Header = ({ setnewClient }) => ( +
+

+ Clients +

+
+
+ + +
+
+
+ +
+
+); + +export default Header; diff --git a/app/javascript/src/components/Clients/List/index.tsx b/app/javascript/src/components/Clients/List/index.tsx index 6dbfcbaef2..b477a08548 100644 --- a/app/javascript/src/components/Clients/List/index.tsx +++ b/app/javascript/src/components/Clients/List/index.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { ToastContainer } from "react-toastify"; import { setAuthHeaders, registerIntercepts } from "apis/axios"; import clients from "apis/clients"; @@ -7,9 +8,11 @@ import AmountBoxContainer from "common/AmountBox"; import ChartBar from "common/ChartBar"; import Table from "common/Table"; -import unmapClientList from "../../../mapper/client.mapper"; +import Header from "./Header"; +import { unmapClientList } from "../../../mapper/client.mapper"; import DeleteClient from "../Modals/DeleteClient"; import EditClient from "../Modals/EditClient"; +import NewClient from "../Modals/NewClient"; const getTableData = (clients) => { if (clients) { @@ -29,10 +32,12 @@ const getTableData = (clients) => { const Clients = ({ isAdminUser }) => { const [showEditDialog, setShowEditDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [edit, setedit] = useState({}); - const [deleteClient, setDelete] = useState({}); + const [newClient, setnewClient] = useState(false); + const [clientToEdit, setedit] = useState({}); + const [clientToDelete, setDelete] = useState({}); const [clientData, setClientData] = useState(); const [totalMinutes, setTotalMinutes] = useState(null); + const navigate = useNavigate(); const handleEditClick = (id) => { setShowEditDialog(true); @@ -55,6 +60,10 @@ const Clients = ({ isAdminUser }) => { }); }; + const handleRowClick = (id) => { + navigate(`${id}`); + }; + useEffect(() => { setAuthHeaders(); registerIntercepts(); @@ -98,6 +107,7 @@ const Clients = ({ isAdminUser }) => { return ( <> +
{ isAdminUser &&
@@ -137,22 +147,30 @@ const Clients = ({ isAdminUser }) => { hasRowIcons={true} tableHeader={tableHeader} tableRowArray={tableData} + rowOnClick={handleRowClick} /> }
- {showEditDialog ? ( + {showEditDialog && - ) : null} + } {showDeleteDialog && ( + )} + {newClient && ( + )} diff --git a/app/javascript/src/components/Clients/Modals/EditClient.tsx b/app/javascript/src/components/Clients/Modals/EditClient.tsx index 46fdef7767..8dc9594f52 100644 --- a/app/javascript/src/components/Clients/Modals/EditClient.tsx +++ b/app/javascript/src/components/Clients/Modals/EditClient.tsx @@ -1,52 +1,45 @@ import React, { useState } from "react"; import clients from "apis/clients"; +import { Formik, Form, Field } from "formik"; import { X } from "phosphor-react"; +import * as Yup from "yup"; + +const newClientSchema = Yup.object().shape({ + name: Yup.string().required("Name cannot be blank"), + email: Yup.string().email("Invalid email ID").required("Email ID cannot be blank"), + phoneNo: Yup.number().typeError("Invalid phone number"), + address: Yup.string().required("Address cannot be blank") +}); + +const getInitialvalues = ({ name, email }) => ({ + name: name, + email: email, + phoneNo: "", + address: "" +}); + export interface IEditClient { setShowEditDialog: any; client: any; } const EditClient = ({ setShowEditDialog, client }: IEditClient) => { - const [name, setName] = useState(client.name); - const [email, setEmail] = useState(client.email); - const [phone, setPhone] = useState(client.phone); - const [address, setAddress] = useState(client.address); - const [errors, setErrors] = useState<{ name: string; email: string }>({ - name: "", - email: "" - }); - const handleSubmit = async e => { - e.preventDefault(); + const [apiError, setApiError] = useState(""); - try { - const res = await clients.update(client.id, { - client: { - name, - email, - phone, - address - } - }); - setTimeout(() => { - if (res.data?.success) { - window.location.reload(); - } - }, 500); - } catch (err) { - if (err.response.status == 422) { - setErrors({ - name: err.response.data.errors.name - ? err.response.data.errors.name[0] - : null, - email: err.response.data.errors.email - ? err.response.data.errors.email[0] - : null - }); + const handleSubmit = async values => { + await clients.update(client.id, { + client: { + ...values } - } + }).then(() => { + setShowEditDialog(false); + }).catch((e) => { + setApiError(e.message); + }); }; + return (
{
-
Edit Client Details
+
Add New Client
-
-
-
-
- -
- {errors.name} + + {({ errors, touched }) => ( + +
+
+
+ +
+ {errors.name && touched.name && +
{errors.name}
+ } +
+
+
+ +
-
- setName(e.target.value)} - /> -
-
-
- -
-
-
- -
- {errors.email} +
+
+
+ +
+ {errors.email && touched.email && +
{errors.email}
+ } +
+
+
+ +
-
- setEmail(e.target.value)} - /> +
+
+
+ +
+ {errors.phoneNo && touched.phoneNo && +
{errors.phoneNo}
+ } +
+
+
+ +
+
-
-
- -
-
-
- -
+
+
+
+ +
+ {errors.address && touched.address && +
{errors.address}
+ } +
+
+
+ +
+
-
+

{apiError}

+
setPhone(e.target.value)} + type="submit" + name="commit" + value="SAVE CHANGES" + className="form__input_submit" />
-
-
- -
-
-
- -
-
-
- -
-
-
- -
- -
- + + )} +
diff --git a/app/javascript/src/components/Clients/Modals/NewClient.tsx b/app/javascript/src/components/Clients/Modals/NewClient.tsx new file mode 100644 index 0000000000..3705c8b2f8 --- /dev/null +++ b/app/javascript/src/components/Clients/Modals/NewClient.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import clients from "apis/clients"; +import { Formik, Form, Field } from "formik"; +import { X } from "phosphor-react"; +import * as Yup from "yup"; + +const newClientSchema = Yup.object().shape({ + name: Yup.string().required("Name cannot be blank"), + email: Yup.string().email("Invalid email ID").required("Email ID cannot be blank"), + phoneNo: Yup.number().typeError("Invalid phone number"), + address: Yup.string().required("Address cannot be blank") +}); + +const initialValues = { + name: "", + email: "", + phoneNo: "", + address: "" +}; + +const EditClient = ({ setnewClient, clientData, setClientData }) => { + const handleSubmit = (values) => { + clients.create(values) + .then(res => { + setClientData([...clientData, { ...res.data }]); + setnewClient(false); + }); + }; + + return ( +
+
+
+
+
+
Add New Client
+ +
+ + {({ errors, touched }) => ( +
+
+
+
+ +
+ {errors.name && touched.name && +
{errors.name}
+ } +
+
+
+ +
+
+
+
+
+
+ +
+ {errors.email && touched.email && +
{errors.email}
+ } +
+
+
+ +
+
+
+
+
+
+ +
+ {errors.phoneNo && touched.phoneNo && +
{errors.phoneNo}
+ } +
+
+
+ +
+
+
+
+
+
+ +
+ {errors.address && touched.address && +
{errors.address}
+ } +
+
+
+ +
+
+
+
+ +
+ + )} +
+
+
+
+
+ ); +}; + +export default EditClient; diff --git a/app/javascript/src/components/Clients/RouteConfig.tsx b/app/javascript/src/components/Clients/RouteConfig.tsx new file mode 100644 index 0000000000..4cc122a7c8 --- /dev/null +++ b/app/javascript/src/components/Clients/RouteConfig.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +import { + BrowserRouter, + Routes, + Route +} from "react-router-dom"; + +import Details from "./Details"; +import ClientList from "./List"; + +const RouteConfig = ({ isAdminUser }) => ( + + + + } /> + } /> + + + +); + +export default RouteConfig; diff --git a/app/javascript/src/mapper/client.mapper.ts b/app/javascript/src/mapper/client.mapper.ts index 3d72170f5c..817eb96306 100644 --- a/app/javascript/src/mapper/client.mapper.ts +++ b/app/javascript/src/mapper/client.mapper.ts @@ -4,7 +4,8 @@ const getClientList = (input) => input.client_details.map((client) => ({ minutes: client.minutes_spent, name: client.name })); -const unmapClient = (input) => { + +const unmapClientList = (input) => { const { data } = input; return { clientList: getClientList(data), @@ -12,4 +13,27 @@ const unmapClient = (input) => { }; }; -export default unmapClient; +const mapProjectDetails = (input) => input.map((project) => ({ + name: project.name, + minutes: project.minutes_spent, + team: project.team +})); + +const unmapClientDetails = (input) => { + const { data } = input; + + return { + clientDetails: { + id: data.client_details.id, + name: data.client_details.name, + email: data.client_details.email + }, + totalMinutes: data.total_minutes, + projectDetails: mapProjectDetails(data.project_details) + }; +}; + +export { + unmapClientList, + unmapClientDetails +}; diff --git a/app/views/clients/index.html.erb b/app/views/clients/index.html.erb index cbfd88108a..cd7bac2dc3 100644 --- a/app/views/clients/index.html.erb +++ b/app/views/clients/index.html.erb @@ -1,68 +1,6 @@
-
-
-
-
-

- Clients -

-
-
-
- - -
-
- <% if policy(new_client).create? %> -
- -
- <% end %> -
- - <%= react_component("Clients/List", { - clients: clients, - editIcon: image_url('edit_image_button.svg'), - deleteIcon: image_url('delete_image_button.svg'), - isAdminUser: current_user.has_owner_or_admin_role?(current_user.current_workspace) }) %> - - <% if policy(new_client).create? %> -
-
- -
-
- <% end %> -
+
+ <%= react_component("Clients/RouteConfig", { + isAdminUser: current_user.has_owner_or_admin_role?(current_user.current_workspace) }) %>
diff --git a/app/views/internal_api/v1/clients/_client.json.jbuilder b/app/views/internal_api/v1/clients/_client.json.jbuilder new file mode 100644 index 0000000000..092b0c3ae1 --- /dev/null +++ b/app/views/internal_api/v1/clients/_client.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.extract! client, :id, :name, :email, :phone, :address diff --git a/app/views/internal_api/v1/clients/create.json.jbuilder b/app/views/internal_api/v1/clients/create.json.jbuilder new file mode 100644 index 0000000000..6b53c23fa6 --- /dev/null +++ b/app/views/internal_api/v1/clients/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "client", locals: { client: } diff --git a/config/routes.rb b/config/routes.rb index 23ec63fb89..f0ab269d76 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,12 +34,15 @@ def draw(routes_name) resources :time_tracking, only: [:index], path: "time-tracking" resources :team, only: [:index, :update, :destroy, :edit] - resources :clients, only: [:index, :create] + # resources :clients, only: [:create] resources :projects, only: [:index, :create] resources :reports, only: [:index] # resources :invoices, only: [:index, :create] resources :workspaces, only: [:update] + get "clients/*path", to: "clients#index", via: :all + get "clients", to: "clients#index" + get "invoices/*path", to: "invoices#index", via: :all get "invoices", to: "invoices#index" diff --git a/config/routes/internal_api.rb b/config/routes/internal_api.rb index 6bd9343443..8bc07d26a7 100644 --- a/config/routes/internal_api.rb +++ b/config/routes/internal_api.rb @@ -2,7 +2,7 @@ namespace :internal_api, defaults: { format: "json" } do namespace :v1 do - resources :clients, only: [:index, :update, :destroy, :show] + resources :clients, only: [:index, :update, :destroy, :show, :create] resources :project, only: [:index] resources :timesheet_entry do collection do diff --git a/package.json b/package.json index 8ddb8bb943..6b8b8d14ec 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "classnames": "^2.3.1", "dayjs": "^1.11.0", "fork-ts-checker-webpack-plugin": "^6.5.0", + "formik": "^2.2.9", "jquery": "^3.6.0", "js-logger": "^1.6.1", "phosphor-react": "^1.4.1", @@ -44,7 +45,8 @@ "toastr": "^2.1.4", "typescript": "^4.5.4", "webpack": "^4.46.0", - "webpack-cli": "^3.3.12" + "webpack-cli": "^3.3.12", + "yup": "^0.32.11" }, "devDependencies": { "@babel/eslint-parser": "^7.16.3", diff --git a/spec/requests/clients/create_spec.rb b/spec/requests/clients/create_spec.rb deleted file mode 100644 index a9129ac9ac..0000000000 --- a/spec/requests/clients/create_spec.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe "Client#index", type: :request do - let(:company) { create(:company) } - let(:user) { create(:user, current_workspace_id: company.id) } - - context "when user is admin" do - before do - create(:company_user, company:, user:) - user.add_role :admin, company - sign_in user - send_request :get, clients_path - end - - context "when client is valid" do - before do - send_request( - :post, clients_path, params: { - client: { - name: "Test Client", - email: "test@example.com", - phone: "Test phone", - address: "India" - } - }) - end - - it "creates a new client" do - expect(Client.count).to eq(1) - end - - it "sets the company_id to current_user" do - expect(user.current_workspace_id).to eq(Company.first.id) - end - - it "redirects to root_path" do - expect(response).to have_http_status(:redirect) - end - end - - context "when client is invalid" do - before do - send_request( - :post, clients_path, params: { - client: { - name: "", - email: "", - phone: "", - address: "" - } - }) - end - - it "will fail" do - expect(response.body).to include("Client creation failed") - end - - it "will not be created" do - expect(Client.count).to eq(0) - end - - it "redirects to root_path" do - expect(response).to have_http_status(:unprocessable_entity) - end - end - end - - context "when user is employee" do - before do - create(:company_user, company:, user:) - user.add_role :employee, company - sign_in user - send_request( - :post, clients_path, params: { - client: { - name: "Test Client", - email: "test@example.com", - phone: "Test phone", - address: "India" - } - }) - end - - it "will not be created" do - expect(Client.count).to eq(0) - end - - it "redirects to root_path" do - expect(response).to have_http_status(:redirect) - end - - it "is not permitted to create client" do - expect(flash[:alert]).to eq("You are not authorized to create client.") - end - end - - context "when unauthenticated" do - it "user will be redirects to sign in path" do - send_request( - :post, clients_path, params: { - client: { - name: "Test Client", - email: "test@example.com", - phone: "Test phone", - address: "India" - } - }) - expect(response).to redirect_to(user_session_path) - expect(flash[:alert]).to eq("You need to sign in or sign up before continuing.") - end - end - - context "when user's current workspace is nil" do - before do - create(:company_user, company:, user:) - user.add_role :employee, company - user.update!(current_workspace_id: nil) - sign_in user - send_request( - :post, clients_path, params: { - client: { - name: "Test Client", - email: "test@example.com", - phone: "Test phone", - address: "India" - } - } - ) - end - - it "redirects to new_company_path" do - expect(response).to have_http_status(:redirect) - expect(response.body).to include("/company/new") - end - end -end diff --git a/spec/requests/clients/index_spec.rb b/spec/requests/clients/index_spec.rb index 9df5150e6c..ecfacaf78b 100644 --- a/spec/requests/clients/index_spec.rb +++ b/spec/requests/clients/index_spec.rb @@ -1,52 +1,53 @@ # frozen_string_literal: true - -require "rails_helper" - -RSpec.describe "Client#index", type: :request do - let(:company) { create(:company) } - let(:user) { create(:user, current_workspace_id: company.id) } - let(:client) { create(:client, company:) } - let(:project) { create(:project, client:) } - - context "when user is admin" do - before do - create(:company_user, company:, user:) - user.add_role :admin, company - create(:timesheet_entry, user:, project:) - sign_in user - send_request :get, clients_path - end - - it "is successful" do - expect(response).to be_successful - end - - it "renders Client#index page" do - expect(response.body).to include("Clients") - expect(response.body).to include("NEW CLIENT") - end - end - - context "when user is employee" do - before do - create(:company_user, company:, user:) - user.add_role :employee, company - create(:timesheet_entry, user:, project:) - sign_in user - send_request :get, clients_path - end - - it "renders Client#index page" do - expect(response.body).to include("Clients") - expect(response.body).not_to include("NEW CLIENT") - end - end - - context "when unauthenticated" do - it "user will be redirects to sign in path" do - send_request :get, clients_path - expect(response).to redirect_to(user_session_path) - expect(flash[:alert]).to eq("You need to sign in or sign up before continuing.") - end - end -end +# # frozen_string_literal: true +# +# require "rails_helper" +# +# RSpec.describe "Client#index", type: :request do +# let(:company) { create(:company) } +# let(:user) { create(:user, current_workspace_id: company.id) } +# let(:client) { create(:client, company:) } +# let(:project) { create(:project, client:) } +# +# context "when user is admin" do +# before do +# create(:company_user, company:, user:) +# user.add_role :admin, company +# create(:timesheet_entry, user:, project:) +# sign_in user +# send_request :get, clients_path +# end +# +# it "is successful" do +# expect(response).to be_successful +# end +# +# it "renders Client#index page" do +# expect(response.body).to include("Clients") +# expect(response.body).to include("NEW CLIENT") +# end +# end +# +# context "when user is employee" do +# before do +# create(:company_user, company:, user:) +# user.add_role :employee, company +# create(:timesheet_entry, user:, project:) +# sign_in user +# send_request :get, clients_path +# end +# +# it "renders Client#index page" do +# expect(response.body).to include("Clients") +# expect(response.body).not_to include("NEW CLIENT") +# end +# end +# +# context "when unauthenticated" do +# it "user will be redirects to sign in path" do +# send_request :get, clients_path +# expect(response).to redirect_to(user_session_path) +# expect(flash[:alert]).to eq("You need to sign in or sign up before continuing.") +# end +# end +# end diff --git a/spec/requests/internal_api/v1/clients/create_spec.rb b/spec/requests/internal_api/v1/clients/create_spec.rb new file mode 100644 index 0000000000..a897ee3ab5 --- /dev/null +++ b/spec/requests/internal_api/v1/clients/create_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "InternalApi::V1::Client#create", type: :request do + let(:company) { create(:company) } + let(:user) { create(:user, current_workspace_id: company.id) } + + context "when user is admin" do + before do + create(:company_user, company:, user:) + user.add_role :admin, company + sign_in user + end + + describe "#create" do + it "creates the client successfully" do + client = attributes_for(:client) + send_request :post, internal_api_v1_clients_path(client:) + expect(response).to have_http_status(:ok) + expected_attrs = [ "address", "email", "id", "name", "phone" ] + expect(json_response.keys.sort).to match(expected_attrs) + end + + it "throws 422 if the name doesn't exist" do + send_request :post, internal_api_v1_clients_path( + client: { + email: "test@client.com", + description: "Rspec Test", + phone: "7777777777", + address: "Somewhere on Earth" + }) + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response["errors"]["name"].first).to eq("can't be blank") + end + end + end + + context "when the user is an employee" do + before do + create(:company_user, company:, user:) + user.add_role :employee, company + sign_in user + send_request :post, internal_api_v1_clients_path + end + + it "is not be permitted to generate a client" do + expect(response).to have_http_status(:forbidden) + end + end + + context "when unauthenticated" do + it "is not be permitted to generate a client" do + send_request :post, internal_api_v1_clients_path + expect(response).to have_http_status(:unauthorized) + expect(json_response["error"]).to eq("You need to sign in or sign up before continuing.") + end + end +end diff --git a/yarn.lock b/yarn.lock index 3456745669..529323df6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -945,7 +945,7 @@ "@babel/helper-validator-option" "^7.16.7" "@babel/plugin-transform-typescript" "^7.16.7" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.0", "@babel/runtime@^7.15.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.17.9" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72" integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg== @@ -1288,6 +1288,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/lodash@^4.14.175": + version "4.14.181" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.181.tgz#d1d3740c379fda17ab175165ba04e2d03389385d" + integrity sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag== + "@types/minimatch@*": version "3.0.5" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" @@ -3237,6 +3242,11 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +deepmerge@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" + integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== + deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" @@ -4292,6 +4302,19 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formik@^2.2.9: + version "2.2.9" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0" + integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^1.10.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -4676,7 +4699,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.1: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -5621,6 +5644,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -6030,6 +6058,11 @@ nan@^2.12.1: resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== +nanoclone@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" + integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== + nanoid@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.2.tgz#c89622fafb4381cd221421c69ec58547a1eec557" @@ -7403,6 +7436,11 @@ prop-types@^15.5.6, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, object-assign "^4.1.1" react-is "^16.13.1" +property-expr@^2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" + integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -7575,6 +7613,11 @@ react-dom@^17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-fast-compare@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + react-infinite-scroll-component@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f" @@ -8843,6 +8886,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-warning@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + tmp@^0.2.1, tmp@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" @@ -8904,6 +8952,11 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA= + tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" @@ -8927,7 +8980,7 @@ tsconfig-paths@^3.14.1: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.8.1: +tslib@^1.10.0, tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -9551,3 +9604,16 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +yup@^0.32.11: + version "0.32.11" + resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" + integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/lodash" "^4.14.175" + lodash "^4.17.21" + lodash-es "^4.17.21" + nanoclone "^0.2.1" + property-expr "^2.0.4" + toposort "^2.0.2" From c324a43ce020050b98accd2c700b4a58138e491f Mon Sep 17 00:00:00 2001 From: Shivam Chahar Date: Tue, 19 Apr 2022 06:18:39 +0530 Subject: [PATCH 03/30] Paginates invoices page (#284) --- app/javascript/src/apis/invoices.ts | 3 +- app/javascript/src/common/Pagination.tsx | 120 +++++++++--------- .../src/components/Invoices/List/Header.tsx | 12 +- .../src/components/Invoices/List/index.tsx | 23 +++- 4 files changed, 91 insertions(+), 67 deletions(-) diff --git a/app/javascript/src/apis/invoices.ts b/app/javascript/src/apis/invoices.ts index a4b7039c0b..a6f83b7e95 100644 --- a/app/javascript/src/apis/invoices.ts +++ b/app/javascript/src/apis/invoices.ts @@ -2,7 +2,8 @@ import axios from "axios"; const path = "/invoices"; -const get = async () => axios.get(`${path}`); +const get = async (query = "") => + axios.get(query ? `${path}?${query}` : `${path}`); const invoicesApi = { get }; diff --git a/app/javascript/src/common/Pagination.tsx b/app/javascript/src/common/Pagination.tsx index 9ceb11a565..260f9258b8 100644 --- a/app/javascript/src/common/Pagination.tsx +++ b/app/javascript/src/common/Pagination.tsx @@ -1,70 +1,72 @@ import * as React from "react"; +import cn from "classnames"; import { CaretCircleLeft, CaretCircleRight } from "phosphor-react"; -const getPaginationButton = (pageCounter) => { - const pageButtons = []; - for (let counter = 1; counter <= pageCounter; counter++) { - pageButtons.push( - - ); - } - return pageButtons; -}; +const Pagination = ({ pagy, params, setParams }) => ( +
+
+
+ {pagy?.pages > 1 && ( +
+ -const Pagination = ({ totalData = 0, pagy }) => { - const [pages, getPages] = React.useState(null); - const [counter, setCounter] = React.useState(30); + {Array.from({ length: pagy.pages }, (_, idx) => idx + 1).map( + (page) => ( + + ) + )} - React.useEffect(() => { - if (totalData && totalData > 0) { - const listPerPage = Math.ceil(totalData / counter); - getPages(listPerPage); - } - }, [counter]); - - const handleSelectChange = (e) => { - setCounter(parseInt(e.target.value)); - }; - - return ( -
-
-
- {pages && pages > 1 && ( -
- - - {getPaginationButton(pages)} - - -
- )} -
+ +
+ )} +
-
- +
+ - invoices per page -
+ invoices per page
- ); -}; +
+); export default Pagination; diff --git a/app/javascript/src/components/Invoices/List/Header.tsx b/app/javascript/src/components/Invoices/List/Header.tsx index aaeab38ba9..83a701f300 100644 --- a/app/javascript/src/components/Invoices/List/Header.tsx +++ b/app/javascript/src/components/Invoices/List/Header.tsx @@ -27,14 +27,17 @@ const Header = ({ className="header__searchInput" placeholder="Search" /> +
+
+
- {selectedInvoiceCount > 1 ? `${selectedInvoiceCount} invoices selected` :`${selectedInvoiceCount} invoice selected` } + + {selectedInvoiceCount > 1 + ? `${selectedInvoiceCount} invoices selected` + : `${selectedInvoiceCount} invoice selected`}{" "} + + +
+
+ + + + + + +); + +export default TableHeader; diff --git a/app/javascript/src/components/payments/Table/TableRow.tsx b/app/javascript/src/components/payments/Table/TableRow.tsx new file mode 100644 index 0000000000..992634ce1f --- /dev/null +++ b/app/javascript/src/components/payments/Table/TableRow.tsx @@ -0,0 +1,45 @@ +import React from "react"; + +const TableRow = ({ member }) => ( + + + + + + + + + + + +); + +export default TableRow; diff --git a/app/javascript/src/components/payments/Table/index.tsx b/app/javascript/src/components/payments/Table/index.tsx new file mode 100644 index 0000000000..e657110890 --- /dev/null +++ b/app/javascript/src/components/payments/Table/index.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import TableHeader from "./TableHeader"; +import TableRow from "./TableRow"; + +const Table = ({ payments }) => ( +
+ CLIENT /
+ INVOICE NUMBER +
+ TRANSACTION
+ DATE {"&"} TIME +
+ NOTES/ +
+ TRANSACTION TYPE +
+ AMOUNT + + STATUS +
+

+ {member.client} +

+

+ {member.invoice_number} +

+
+

+ {member.time} +

+

+ {member.transaction_date} +

+
+ {member.transaction_type} + + ${member.amount} + + {member.status == "Failed" ? ( + + Failed + + ) : ( + + Paid + + )} +
+ + + + + + {payments.map((member) => member && )} + +
+); + +export default Table; diff --git a/app/javascript/src/components/payments/index.tsx b/app/javascript/src/components/payments/index.tsx new file mode 100644 index 0000000000..83acdc5e5c --- /dev/null +++ b/app/javascript/src/components/payments/index.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import Pagination from "common/Pagination"; +import Header from "./Header"; +import Table from "./Table/index"; + +const Payments = () => { + const payments = [ + { + invoice_number: "1", + client: "Facebook", + time: "1:20 PM", + transaction_date: "2022-04-12", + transaction_type: "Payment_Stripe_Auto_Succes", + amount: "300", + status: "paid" + }, + { + invoice_number: "2", + client: "Slack", + time: "1:20 PM", + transaction_date: "2022-04-12", + transaction_type: "Payment_Stripe_Auto_Succes", + amount: "300", + status: "paid" + }, + { + invoice_number: "3", + client: "Upwork", + time: "1:20 PM", + transaction_date: "2022-04-12", + transaction_type: "Payment_Stripe_Auto_Failed", + amount: "300", + status: "Failed" + }, + { + invoice_number: "4", + client: "Youtube", + time: "1:20 PM", + transaction_date: "2022-04-12", + transaction_type: "Payment_Stripe_Auto_Succes", + amount: "300", + status: "paid" + }, + { + invoice_number: "5", + client: "Reddit", + time: "1:20 PM", + transaction_date: "2022-04-12", + transaction_type: "Payment_Stripe_Auto_failure", + amount: "300", + status: "Failed" + }, + { + invoice_number: "5", + client: "Reddit", + time: "1:20 PM", + transaction_date: "2022-04-12", + transaction_type: "Payment_Stripe_Auto_failure", + amount: "300", + status: "Failed" + }, + { + invoice_number: "5", + client: "Reddit", + time: "1:20 PM", + transaction_date: "2022-04-12", + transaction_type: "Payment_Stripe_Auto_failure", + amount: "300", + status: "Failed" + }, + { + invoice_number: "5", + client: "Reddit", + time: "1:20 PM", + transaction_date: "2022-04-12", + transaction_type: "Payment_Stripe_Auto_failure", + amount: "300", + status: "Failed" + }, + { + invoice_number: "5", + client: "Reddit", + time: "1:20 PM", + transaction_date: "2022-04-12", + transaction_type: "Payment_Stripe_Auto_failure", + amount: "300", + status: "Failed" + } + ]; + + return ( +
+
+ + + + ); +}; + +export default Payments; diff --git a/app/views/payments/index.html.erb b/app/views/payments/index.html.erb index 85cb02004b..41b98a81c0 100644 --- a/app/views/payments/index.html.erb +++ b/app/views/payments/index.html.erb @@ -1 +1,9 @@ -

Code

+
+
+
+ <%= react_component("payments", { + isAdminUser: current_user.has_owner_or_admin_role?(current_user.current_workspace) + }) %> +
+
+
From ff3a78a611cc8961f47168b438d1d33b45c38194 Mon Sep 17 00:00:00 2001 From: Murtaza Bagwala Date: Wed, 20 Apr 2022 18:43:43 +0530 Subject: [PATCH 10/30] Update time entry status to billed once the invoice is sent to the client (#292) * update time entry status to billed once the invoice status is sent to the client * fix comments and add unit test case --- .../internal_api/v1/invoices_controller.rb | 2 +- app/models/invoice.rb | 5 +++++ app/models/timesheet_entry.rb | 2 +- db/schema.rb | 2 +- spec/models/invoice_spec.rb | 9 +++++++++ .../internal_api/v1/invoices/send_invoice_spec.rb | 11 +++++++++-- 6 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/controllers/internal_api/v1/invoices_controller.rb b/app/controllers/internal_api/v1/invoices_controller.rb index 1d7825f8dd..7da9105762 100644 --- a/app/controllers/internal_api/v1/invoices_controller.rb +++ b/app/controllers/internal_api/v1/invoices_controller.rb @@ -47,7 +47,7 @@ def send_invoice authorize invoice invoice.send_to_email(subject: invoice_email_params[:subject], recipients: invoice_email_params[:recipients]) - + invoice.update_timesheet_entry_status! render json: { message: "Invoice will be sent!" }, status: :accepted end diff --git a/app/models/invoice.rb b/app/models/invoice.rb index 4b2726d5bf..1ecd92e9d2 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -62,4 +62,9 @@ class Invoice < ApplicationRecord def sub_total @_sub_total ||= invoice_line_items.sum { |line_item| line_item[:rate] * line_item[:quantity] } end + + def update_timesheet_entry_status! + timesheet_entry_ids = invoice_line_items.pluck(:timesheet_entry_id) + TimesheetEntry.where(id: timesheet_entry_ids).update!(bill_status: :billed) + end end diff --git a/app/models/timesheet_entry.rb b/app/models/timesheet_entry.rb index 175ba3e3de..2d43123772 100644 --- a/app/models/timesheet_entry.rb +++ b/app/models/timesheet_entry.rb @@ -8,7 +8,7 @@ # user_id :integer not null # project_id :integer not null # duration :float not null -# note :text +# note :text default("") # work_date :date not null # bill_status :integer not null # created_at :datetime not null diff --git a/db/schema.rb b/db/schema.rb index 46fd601c72..659a7c39b1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -164,7 +164,7 @@ t.bigint "user_id", null: false t.bigint "project_id", null: false t.float "duration", null: false - t.text "note" + t.text "note", default: "" t.date "work_date", null: false t.integer "bill_status", null: false t.datetime "created_at", precision: 6, null: false diff --git a/spec/models/invoice_spec.rb b/spec/models/invoice_spec.rb index e1b686d0e5..a445f98f4a 100644 --- a/spec/models/invoice_spec.rb +++ b/spec/models/invoice_spec.rb @@ -124,4 +124,13 @@ expect { invoice.send_to_email(subject:, recipients:) }.to have_enqueued_mail(InvoiceMailer, :invoice) end end + + describe ".update_timesheet_entry_status" do + it "updates the time_sheet_entries status to billed" do + invoice.update_timesheet_entry_status! + invoice.invoice_line_items.reload.each do |line_item| + expect(line_item.timesheet_entry.bill_status).to eq("billed") + end + end + end end diff --git a/spec/requests/internal_api/v1/invoices/send_invoice_spec.rb b/spec/requests/internal_api/v1/invoices/send_invoice_spec.rb index fdc99ea30e..81c556a951 100644 --- a/spec/requests/internal_api/v1/invoices/send_invoice_spec.rb +++ b/spec/requests/internal_api/v1/invoices/send_invoice_spec.rb @@ -3,9 +3,9 @@ require "rails_helper" RSpec.describe "InternalApi::V1::Invoices#send_invoice", type: :request do - let(:client) { create :client_with_invoices } + let(:invoice) { create :invoice_with_invoice_line_items } + let(:client) { invoice.client } let(:company) { client.company } - let(:invoice) { client.invoices.first } let(:user) { create :user, current_workspace_id: company.id } context "when user is signed in" do @@ -36,6 +36,13 @@ end.to have_enqueued_mail(InvoiceMailer, :invoice) end + it "changes time_sheet_entries status to billed" do + post send_invoice_internal_api_v1_invoice_path(id: invoice.id), params: { invoice_email: } + invoice.invoice_line_items.reload.each do |line_item| + expect(line_item.timesheet_entry.bill_status).to eq("billed") + end + end + context "when invoice doesn't exist" do it "returns 404 response" do post send_invoice_internal_api_v1_invoice_path(id: "random") From 5d9772ddd26c04412e82e2d87516faff190c6760 Mon Sep 17 00:00:00 2001 From: Ajinkya Deshmukh Date: Thu, 21 Apr 2022 12:21:58 +0530 Subject: [PATCH 11/30] Project routes addition (#289) * Project list ADD/Edit Modal * Updated code * routes added for projects * invoice amount box update * ui fixes * comment create method in project_controller for erb, comment create_spec.rb for create action in erb * removed create action from project_controller for erb * routes updates Co-authored-by: Shruti Co-authored-by: Aniket Kaushik --- app/controllers/projects_controller.rb | 12 - .../src/components/Projects/Details/index.tsx | 19 +- .../src/components/Projects/List/index.tsx | 25 +- .../src/components/Projects/List/project.tsx | 6 +- .../Projects/Modals/AddEditProject.tsx | 2 - .../src/components/Projects/RouteConfig.tsx | 27 ++ .../src/components/Projects/index.tsx | 49 --- .../src/components/Projects/interface.ts | 3 +- app/views/projects/index.html.erb | 12 +- config/routes.rb | 8 +- spec/requests/projects/create_spec.rb | 302 ++++++++---------- 11 files changed, 214 insertions(+), 251 deletions(-) create mode 100644 app/javascript/src/components/Projects/RouteConfig.tsx delete mode 100644 app/javascript/src/components/Projects/index.tsx diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index f9d9877c35..cd72c90518 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -7,18 +7,6 @@ def index authorize Project end - def create - project = Project.new(project_params) - - if project.save - flash[:notice] = t(".success") - else - flash[:alert] = t(".failure") - end - - redirect_to projects_path - end - private def project_params diff --git a/app/javascript/src/components/Projects/Details/index.tsx b/app/javascript/src/components/Projects/Details/index.tsx index e01daa29b9..9d7914c6fe 100644 --- a/app/javascript/src/components/Projects/Details/index.tsx +++ b/app/javascript/src/components/Projects/Details/index.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useParams, useNavigate } from "react-router-dom"; import { setAuthHeaders, registerIntercepts } from "apis/axios"; import projectAPI from "apis/projects"; import AmountBoxContainer from "common/AmountBox"; @@ -23,19 +24,21 @@ const getTableData = (project) => { } }; -const ProjectDetails = ({ id }) => { +const ProjectDetails = () => { const [project, setProject] = React.useState(); const [showAddMemberDialog, setShowAddMemberDialog] = React.useState(false); const [isHeaderMenuVisible, setHeaderMenuVisibility] = React.useState(false); + const params = useParams(); + const navigate = useNavigate(); const fetchProject = async () => { - try { - const resp = await projectAPI.show(id); - setProject(unmapper(resp.data.project_details)); - } catch (err) { + await projectAPI.show(params.projectId) + .then(resp => { + setProject(unmapper(resp.data.project_details)); + }).catch(() => { // Add error handling - } + }); }; const handleAddProjectDetails = () => { @@ -99,7 +102,7 @@ const ProjectDetails = ({ id }) => {
-

@@ -185,7 +188,7 @@ const ProjectDetails = ({ id }) => { setShowAddMemberDialog={setShowAddMemberDialog} addedMembers={project?.members} handleAddProjectDetails = {handleAddProjectDetails} - projectId={id} + projectId={1} /> ) : null} diff --git a/app/javascript/src/components/Projects/List/index.tsx b/app/javascript/src/components/Projects/List/index.tsx index f80c39a27f..db10bbb2a9 100644 --- a/app/javascript/src/components/Projects/List/index.tsx +++ b/app/javascript/src/components/Projects/List/index.tsx @@ -1,13 +1,32 @@ import * as React from "react"; import { ToastContainer } from "react-toastify"; +import { setAuthHeaders, registerIntercepts } from "apis/axios"; +import projectApi from "apis/projects"; import Header from "./Header"; import { Project } from "./project"; +import { IProject } from "../interface"; import AddEditProject from "../Modals/AddEditProject"; -export const ProjectList = ({ allProjects, isAdminUser, projectClickHandler }) => { +export const ProjectList = ({ isAdminUser }) => { const [showProjectModal, setShowProjectModal] = React.useState(false); const [editProjectData, setEditProjectData] = React.useState(null); + const [projects, setProjects] = React.useState([]); + + const fetchProjects = async () => { + await projectApi.get() + .then(resp => { + setProjects(resp.data.projects); + }).catch(() => { + //error handling + }); + }; + + React.useEffect(() => { + setAuthHeaders(); + registerIntercepts(); + fetchProjects(); + }, []); return ( @@ -45,14 +64,12 @@ export const ProjectList = ({ allProjects, isAdminUser, projectClickHandler }) =

- {allProjects.map((project, index) => ( + {projects.map((project, index) => ( ))} diff --git a/app/javascript/src/components/Projects/List/project.tsx b/app/javascript/src/components/Projects/List/project.tsx index de608ba004..e9a3607d43 100644 --- a/app/javascript/src/components/Projects/List/project.tsx +++ b/app/javascript/src/components/Projects/List/project.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useNavigate } from "react-router-dom"; import { minutesToHHMM } from "helpers/hhmm-parser"; import { Pen, Trash } from "phosphor-react"; import { IProject } from "../interface"; @@ -10,12 +11,12 @@ export const Project = ({ minutesSpent, isBillable, isAdminUser, - projectClickHandler, setShowProjectModal, setEditProjectData }: IProject) => { const [grayColor, setGrayColor] = React.useState(""); const [isHover, setHover] = React.useState(false); + const navigate = useNavigate(); const handleMouseEnter = () => { setGrayColor("bg-miru-gray-100"); @@ -26,6 +27,9 @@ export const Project = ({ setGrayColor(""); setHover(false); }; + const projectClickHandler = (id) => { + navigate(`${id}`); + }; return ( ( + + + + + } /> + } /> + } /> + + + + +); + +export default RouteConfig; diff --git a/app/javascript/src/components/Projects/index.tsx b/app/javascript/src/components/Projects/index.tsx deleted file mode 100644 index f027a6cb88..0000000000 --- a/app/javascript/src/components/Projects/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from "react"; -import { setAuthHeaders, registerIntercepts } from "apis/axios"; -import projectApi from "apis/projects"; -import ProjectDetails from "./Details"; -import { IProject } from "./interface"; -import ProjectList from "./List"; - -const Projects = ({ isAdminUser }) => { - - const [showProjectDetails, setShowProjectDetails] = React.useState(null); - const [projects, setProjects] = React.useState([]); - - const fetchProjects = async () => { - try { - const resp = await projectApi.get(); - setProjects(resp.data.projects); - } catch (error) { - // Add error handling - } - }; - - React.useEffect(() => { - setAuthHeaders(); - registerIntercepts(); - fetchProjects(); - }, []); - - const projectClickHandler = (id) => { - if (isAdminUser) - { setShowProjectDetails(id); } - }; - - return ( - - {showProjectDetails ? - : - } - - ); - -}; - -export default Projects; diff --git a/app/javascript/src/components/Projects/interface.ts b/app/javascript/src/components/Projects/interface.ts index e47a825114..6f002d0e7e 100644 --- a/app/javascript/src/components/Projects/interface.ts +++ b/app/javascript/src/components/Projects/interface.ts @@ -13,7 +13,8 @@ export interface IProject { setShowDeleteDialog: any; projectClickHandler: any; setShowProjectModal:any; - setEditProjectData:any + setEditProjectId:any; + setEditProjectData:any; } export interface IMember { diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb index 96a4fa8251..5c768a2e96 100644 --- a/app/views/projects/index.html.erb +++ b/app/views/projects/index.html.erb @@ -1,11 +1,7 @@
-
-
- <%= react_component("Projects", { - isAdminUser: current_user.has_owner_or_admin_role?(current_user.current_workspace) - }) %> - - -
+
+ <%= react_component("Projects/RouteConfig", { + isAdminUser: current_user.has_owner_or_admin_role?(current_user.current_workspace) + }) %>
diff --git a/config/routes.rb b/config/routes.rb index cfe3e83cf8..92d4dc1c38 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -36,11 +36,8 @@ def draw(routes_name) resources :time_tracking, only: [:index], path: "time-tracking" resources :team, only: [:index, :update, :destroy, :edit] - # resources :clients, only: [:create] - resources :projects, only: [:index, :create] + resources :reports, only: [:index] - # resources :invoices, only: [:index, :create] - # resources :payments, only: [:index] resources :workspaces, only: [:update] get "clients/*path", to: "clients#index", via: :all @@ -49,6 +46,9 @@ def draw(routes_name) get "invoices/*path", to: "invoices#index", via: :all get "invoices", to: "invoices#index" + get "projects/*path", to: "projects#index", via: :all + get "projects", to: "projects#index" + get "payments/*path", to: "payments#index", via: :all get "payments", to: "payments#index" diff --git a/spec/requests/projects/create_spec.rb b/spec/requests/projects/create_spec.rb index e45c7e52ed..9a9e7b86a2 100644 --- a/spec/requests/projects/create_spec.rb +++ b/spec/requests/projects/create_spec.rb @@ -1,163 +1,141 @@ # frozen_string_literal: true - -require "rails_helper" - -RSpec.describe "Projects#create", type: :request do - let(:company) { create(:company) } - let(:user) { create(:user, current_workspace_id: company.id) } - let(:client) { create(:client, company:) } - - context "when user is admin" do - before do - create(:company_user, company:, user:) - user.add_role :admin, company - sign_in user - end - - context "when project is valid" do - before do - send_request( - :post, projects_path, params: { - project: { - client_id: client.id, - name: "Test project", - billable: true - } - }) - end - - it "creates a new project" do - change(Project, :count).by(1) - end - - it "returns success flash notice" do - expect(flash[:notice]).to eq("Project added successfully.") - end - - it "redirects to root_path" do - expect(response).to have_http_status(:redirect) - end - end - - context "when project is invalid" do - before do - send_request( - :post, projects_path, params: { - project: { - client_id: client.id, - name: "", - billable: true - } - }) - end - - it "will not be created" do - change(Project, :count).by(0) - end - - it "returns failed flash alert" do - expect(flash[:alert]).to eq("Project creation failed.") - end - - it "redirects to root_path" do - expect(response).to have_http_status(:redirect) - end - end - end - - context "when user is employee" do - before do - create(:company_user, company:, user:) - user.add_role :employee, company - sign_in user - end - - context "when project is valid" do - before do - send_request( - :post, projects_path, params: { - project: { - client_id: client.id, - name: "Test project", - billable: true - } - }) - end - - it "will be created" do - change(Project, :count).by(1) - end - - it "returns success flash" do - expect(flash[:notice]).to eq("Project added successfully.") - end - - it "redirects to root_path" do - expect(response).to have_http_status(:redirect) - end - end - - context "when project is invalid" do - before do - send_request( - :post, projects_path, params: { - project: { - client_id: client.id, - name: "", - billable: true - } - }) - end - - it "will not be created" do - change(Project, :count).by(0) - end - - it "returns failed flash alert" do - expect(flash[:alert]).to eq("Project creation failed.") - end - - it "redirects to root_path" do - expect(response).to have_http_status(:redirect) - end - end - end - - context "when unauthenticated" do - it "user will be redirects to sign in path" do - send_request( - :post, projects_path, params: { - project: { - client_id: client.id, - name: "Test project", - billable: true - } - }) - expect(response).to redirect_to(user_session_path) - expect(flash[:alert]).to eq("You need to sign in or sign up before continuing.") - end - end - - context "when user's current workspace is nil" do - before do - create(:company_user, company:, user:) - user.add_role :employee, company - user.update!(current_workspace_id: nil) - sign_in user - send_request( - :post, projects_path, params: { - project: { - client_id: client.id, - name: "Test project", - billable: true - } - } - ) - end - - it "redirects to new_company_path" do - expect(response).to have_http_status(:redirect) - expect(response.body).to include("/company/new") - end - end -end +# # frozen_string_literal: true +# +# require "rails_helper" +# +# RSpec.describe "Projects#create", type: :request do +# let(:company) { create(:company) } +# let(:user) { create(:user, current_workspace_id: company.id) } +# let(:client) { create(:client, company:) } +# +# context "when user is admin" do +# before do +# create(:company_user, company:, user:) +# user.add_role :admin, company +# sign_in user +# end +# +# context "when project is valid" do +# before do +# send_request( +# :post, projects_path, params: { +# project: { +# client_id: client.id, +# name: "Test project", +# billable: true +# } +# }) +# end +# +# it "creates a new project" do +# change(Project, :count).by(1) +# end +# +# it "returns success flash notice" do +# expect(flash[:notice]).to eq("Project added successfully.") +# end +# +# it "redirects to root_path" do +# expect(response).to have_http_status(:redirect) +# end +# end +# +# context "when project is invalid" do +# before do +# send_request( +# :post, projects_path, params: { +# project: { +# client_id: client.id, +# name: "", +# billable: true +# } +# }) +# end +# +# it "will not be created" do +# change(Project, :count).by(0) +# end +# +# it "returns failed flash alert" do +# expect(flash[:alert]).to eq("Project creation failed.") +# end +# +# it "redirects to root_path" do +# expect(response).to have_http_status(:redirect) +# end +# end +# end +# +# context "when user is employee" do +# before do +# create(:company_user, company:, user:) +# user.add_role :employee, company +# sign_in user +# end +# +# context "when project is valid" do +# before do +# send_request( +# :post, projects_path, params: { +# project: { +# client_id: client.id, +# name: "Test project", +# billable: true +# } +# }) +# end +# +# it "will be created" do +# change(Project, :count).by(1) +# end +# +# it "returns success flash" do +# expect(flash[:notice]).to eq("Project added successfully.") +# end +# +# it "redirects to root_path" do +# expect(response).to have_http_status(:redirect) +# end +# end +# +# context "when project is invalid" do +# before do +# send_request( +# :post, projects_path, params: { +# project: { +# client_id: client.id, +# name: "", +# billable: true +# } +# }) +# end +# +# it "will not be created" do +# change(Project, :count).by(0) +# end +# +# it "returns failed flash alert" do +# expect(flash[:alert]).to eq("Project creation failed.") +# end +# +# it "redirects to root_path" do +# expect(response).to have_http_status(:redirect) +# end +# end +# end +# +# context "when unauthenticated" do +# it "user will be redirects to sign in path" do +# send_request( +# :post, projects_path, params: { +# project: { +# client_id: client.id, +# name: "Test project", +# billable: true +# } +# }) +# expect(response).to redirect_to(user_session_path) +# expect(flash[:alert]).to eq("You need to sign in or sign up before continuing.") +# end +# end +# end From ab03f15fadaba0572414f182cc567fd6359be23e Mon Sep 17 00:00:00 2001 From: Shivam Chahar Date: Thu, 21 Apr 2022 12:49:59 +0530 Subject: [PATCH 12/30] Integrates `Send Invoice` button on invoices page (#298) * Sends invoice to client * Fix indentation --- .../internal_api/v1/invoices_controller.rb | 15 +- app/javascript/src/apis/invoices.ts | 5 +- .../Invoices/List/SendInvoice/index.tsx | 242 ++++++++++++++++++ .../Invoices/List/SendInvoice/utils.ts | 29 +++ .../Invoices/List/Table/TableHeader.tsx | 1 + .../Invoices/List/Table/TableRow.tsx | 22 +- app/mailers/invoice_mailer.rb | 1 + app/models/concerns/invoice_sendable.rb | 4 +- app/models/invoice.rb | 1 + .../v1/invoices/index.json.jbuilder | 1 + spec/mailers/previews/invoice_preview.rb | 3 +- spec/models/invoice_spec.rb | 11 +- .../v1/invoices/send_invoice_spec.rb | 2 +- tailwind.config.js | 3 + 14 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 app/javascript/src/components/Invoices/List/SendInvoice/index.tsx create mode 100644 app/javascript/src/components/Invoices/List/SendInvoice/utils.ts diff --git a/app/controllers/internal_api/v1/invoices_controller.rb b/app/controllers/internal_api/v1/invoices_controller.rb index 7da9105762..a7f8d01415 100644 --- a/app/controllers/internal_api/v1/invoices_controller.rb +++ b/app/controllers/internal_api/v1/invoices_controller.rb @@ -2,6 +2,7 @@ class InternalApi::V1::InvoicesController < InternalApi::V1::ApplicationController before_action :load_client, only: [:create, :update] + after_action :ensure_time_entries_billed, only: [:send_invoice] def index authorize Invoice @@ -46,8 +47,12 @@ def update def send_invoice authorize invoice - invoice.send_to_email(subject: invoice_email_params[:subject], recipients: invoice_email_params[:recipients]) - invoice.update_timesheet_entry_status! + invoice.send_to_email( + subject: invoice_email_params[:subject], + message: invoice_email_params[:message], + recipients: invoice_email_params[:recipients] + ) + render json: { message: "Invoice will be sent!" }, status: :accepted end @@ -69,6 +74,10 @@ def invoice_params end def invoice_email_params - params.require(:invoice_email).permit(:subject, :body, recipients: []) + params.require(:invoice_email).permit(:subject, :message, recipients: []) + end + + def ensure_time_entries_billed + invoice.update_timesheet_entry_status! end end diff --git a/app/javascript/src/apis/invoices.ts b/app/javascript/src/apis/invoices.ts index 0460e6553d..6dfcf22a71 100644 --- a/app/javascript/src/apis/invoices.ts +++ b/app/javascript/src/apis/invoices.ts @@ -9,6 +9,9 @@ const post = async (body) => axios.post(`${path}`, body); const patch = async (id, body) => axios.post(`${path}/${id}`, body); -const invoicesApi = { get, post, patch }; +const sendInvoice = async (id, payload) => + axios.post(`${path}/${id}/send_invoice`, payload); + +const invoicesApi = { get, post, patch, sendInvoice }; export default invoicesApi; diff --git a/app/javascript/src/components/Invoices/List/SendInvoice/index.tsx b/app/javascript/src/components/Invoices/List/SendInvoice/index.tsx new file mode 100644 index 0000000000..aa29256a9e --- /dev/null +++ b/app/javascript/src/components/Invoices/List/SendInvoice/index.tsx @@ -0,0 +1,242 @@ +import React, { + FormEvent, + KeyboardEvent, + useEffect, + useRef, + useState +} from "react"; + +import invoicesApi from "apis/invoices"; +import cn from "classnames"; +import Toastr from "common/Toastr"; +import useOutsideClick from "helpers/outsideClick"; +import { X } from "phosphor-react"; + +import { + isEmailValid, + emailSubject, + emailBody, + isDisabled, + buttonText +} from "./utils"; + +import { ApiStatus as InvoiceStatus } from "../../../../constants"; + +interface InvoiceEmail { + subject: string; + message: string; + recipients: string[]; +} + +const Recipient: React.FC<{ email: string; handleClick: any }> = ({ + email, + handleClick +}) => ( +
+

{email}

+ + +
+); + +const SendInvoice: React.FC = ({ invoice, setIsSending, isSending }) => { + const [status, setStatus] = useState(InvoiceStatus.IDLE); + const [invoiceEmail, setInvoiceEmail] = useState({ + subject: emailSubject(invoice), + message: emailBody(invoice), + recipients: [invoice.client.email] + }); + const [newRecipient, setNewRecipient] = useState(""); + const [width, setWidth] = useState("10ch"); + + const modal = useRef(); + const input: React.RefObject = useRef(); + + useOutsideClick(modal, () => setIsSending(false), isSending); + useEffect(() => { + const length = newRecipient.length; + + setWidth(`${length > 10 ? length : 10}ch`); + }, [newRecipient]); + + const handleSubmit = async (event: FormEvent) => { + try { + event.preventDefault(); + setStatus(InvoiceStatus.LOADING); + + const payload = { invoice_email: invoiceEmail }; + const { + data: { notice } + } = await invoicesApi.sendInvoice(invoice.id, payload); + + Toastr.success(notice); + setStatus(InvoiceStatus.SUCCESS); + } catch (error) { + setStatus(InvoiceStatus.ERROR); + } + }; + + const handleRemove = (recipient: string) => { + const recipients = invoiceEmail.recipients.filter((r) => r !== recipient); + + setInvoiceEmail({ + ...invoiceEmail, + recipients + }); + }; + + const handleInput = (event: KeyboardEvent) => { + const recipients = invoiceEmail.recipients; + + if (isEmailValid(newRecipient) && event.key === "Enter") { + setInvoiceEmail({ + ...invoiceEmail, + recipients: recipients.concat(newRecipient) + }); + setNewRecipient(""); + } + }; + + return ( +
+
+ + + + +
+
+
+
+ Send Invoice #{invoice.invoiceNumber} +
+ +
+ +
+
+ + +
input.current.focus()} + className={cn( + "p-1.5 rounded bg-miru-gray-100 flex flex-wrap", + { "h-9": !invoiceEmail.recipients } + )} + > + {invoiceEmail.recipients.map((recipient) => ( + handleRemove(recipient)} + /> + ))} + + setNewRecipient(e.target.value.trim())} + onKeyDown={handleInput} + /> +
+
+ +
+ + + + setInvoiceEmail({ + ...invoiceEmail, + subject: e.target.value + }) + } + /> +
+ +
+ + +