diff --git a/app/assets/images/Background.svg b/app/assets/images/Background.svg new file mode 100644 index 0000000000..0fb2bc90de --- /dev/null +++ b/app/assets/images/Background.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/Banner.svg b/app/assets/images/Banner.svg new file mode 100644 index 0000000000..2dd304c43b --- /dev/null +++ b/app/assets/images/Banner.svg @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/Password.svg b/app/assets/images/Password.svg new file mode 100644 index 0000000000..3841435946 --- /dev/null +++ b/app/assets/images/Password.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/assets/stylesheets/mailers.css b/app/assets/stylesheets/mailers.css new file mode 100644 index 0000000000..c14fc52e0e --- /dev/null +++ b/app/assets/stylesheets/mailers.css @@ -0,0 +1,69 @@ +.background { + position: absolute; +} + +.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; + font-style: normal; + font-weight: 800; + font-size: 24px; + line-height: 33px; + 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; + 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: flex; + justify-content: center; + align-items: center; + } + } +} + +.footer-container { + justify-content: center; + display: flex; + + #footer { + width: 640px; + } +} 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/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/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/apis/clients.ts b/app/javascript/src/apis/clients.ts index 9111ea85dd..a4906dbeaa 100644 --- a/app/javascript/src/apis/clients.ts +++ b/app/javascript/src/apis/clients.ts @@ -2,13 +2,20 @@ import axios from "./api"; const path = "/clients"; +const formHeaders = { + headers: { + "Content-Type": "multipart/form-data", + }, +}; + const get = async queryParam => axios.get(`${path}${queryParam}`); -const create = async payload => axios.post(`${path}`, payload); +const create = async payload => axios.post(`${path}`, payload, formHeaders); const show = async (id, queryParam) => axios.get(`${path}/${id}${queryParam}`); -const update = async (id, payload) => axios.patch(`${path}/${id}`, payload); +const update = async (id, payload) => + axios.patch(`${path}/${id}`, payload, formHeaders); const destroy = async id => axios.delete(`${path}/${id}`); diff --git a/app/javascript/src/apis/companies.ts b/app/javascript/src/apis/companies.ts index 4306ee6324..1363660b00 100644 --- a/app/javascript/src/apis/companies.ts +++ b/app/javascript/src/apis/companies.ts @@ -12,11 +12,18 @@ const authApi = axios.create({ }, }); +const formHeaders = { + headers: { + "Content-Type": "multipart/form-data", + }, +}; + const index = async () => axios.get(`${path}`); -const create = payload => authApi.post(path, payload); +const create = payload => authApi.post(path, payload, formHeaders); -const update = (id, payload) => axios.put(`${path}/${id}`, payload); +const update = (id, payload) => + axios.put(`${path}/${id}`, payload, formHeaders); const destroy = id => axios.delete(`${path}/${id}`); 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/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/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/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/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/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/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/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]]", 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 = () => ( + + + + CATEGORY + + + DATE + + + VENDOR + + + TYPE + + + CATEGORY/
+ Vendor + + + TYPE/
+ DATE + + + AMOUNT + + + +); + +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)} + > + +
+ {getCategoryIcon()} +
+ {categoryName} +
+
+ + + {date} + + +
+ {!isDesktop && getCategoryIcon()} +
+ + {vendorName} + + + clientName + +
+
+ + +
+ {expenseType} +
+
{date}
+
+
+ + + {currencyFormat(currency, amount)} + {isDesktop && ( + + )} + + + { + e.preventDefault(); + e.stopPropagation(); + setShowMoreOptions(true); + }} + /> + + + {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 ( + + + + {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/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 && ( + + )}
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/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/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/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/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")} /> 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 ) => ( ( +
+
+
+ + + 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/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" 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/javascript/src/components/Reports/Container/index.tsx b/app/javascript/src/components/Reports/Container/index.tsx index 2c93f6d47b..8a49d8bc65 100644 --- a/app/javascript/src/components/Reports/Container/index.tsx +++ b/app/javascript/src/components/Reports/Container/index.tsx @@ -84,33 +84,37 @@ const Container = ({ selectedFilter }: ContainerProps) => { {timeEntryReport.reports?.length > 0 ? ( getAlphabeticallySortedReportList(timeEntryReport.reports)?.map( - ({ label, clientLogo, entries }, index) => ( - - {label !== "" && ( -
-
- {getTableLogo( - selectedFilter?.groupBy?.value || null, - clientLogo + ({ label, entries }, index) => { + const clientLogo = entries[0]?.clientLogo || ""; + + return ( + + {label !== "" && ( +
+
+ {getTableLogo( + selectedFilter?.groupBy?.value || null, + clientLogo + )} +

+ {label} +

+
+ {entries?.length > 0 && ( +

+ Total Hours for {label} :   + {getTotalHoursLogged(entries)} +

)} -

- {label} -

- {entries?.length > 0 && ( -

- Total Hours for {label} :   - {getTotalHoursLogged(entries)} -

- )} + )} + +
+ {entries.length > 0 && getEntryList(entries)}
- )} - -
- {entries.length > 0 && getEntryList(entries)} -
-
- ) + + ); + } ) ) : ( {
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; 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/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 ( -
    - - - - - {isAdminUser && ( - - - - ); - } - - return ( - - - - - - - ); -}; +import React from "react"; +const TableHead = () => ( + + + + + + + + +); 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..205c720f81 100644 --- a/app/javascript/src/components/Team/List/Table/TableRow.tsx +++ b/app/javascript/src/components/Team/List/Table/TableRow.tsx @@ -1,192 +1,132 @@ -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; - } else { - isAdminUser ? navigate(`/team/${id}/options`, { replace: true }) : null; - } + navigate(`/team/${id}`, { replace: true }); }; - const formattedRole = role - .split("_") - .map(word => word.charAt(0) + word.slice(1)) - .join(""); - if (isDesktop) { - return ( - - - - - {isAdminUser && ( - - - - - )} - - ); - } - return ( - <> - - - + - + + - - {showMoreOptions && ( - + + + + {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 ( -
    - NAME - - EMAIL ID - - ROLE - - - - )} -
    - NAME / EMAIL ID - - ROLE - -
    + USER + + SALARY + + ROLE + + TYPE +
    {name} - {email} - - {formattedRole} - - {status && ( - - )} - - {actionIconVisible && ( -
    - {status && ( - - )} - - -
    - )} -
    -
    -
    {name}
    -
    -
    -
    {email}
    -
    -
    - {formattedRole} - {status && ( -
    +
    +
    + +
    +
    +
    +

    + {name} +

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

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

    + {status && ( + )} -
    +
    {employmentType || "-"}
    +
    + {calculateWorkDuration(joinedAtDate) || "-"} +
    + {isDesktop && } +
    - - - {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..cbafd401cd 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"; @@ -50,10 +50,8 @@ const TeamList = () => { }; useEffect(() => { - if (modal == TeamModalType.NONE) { - getTeamList(); - } - }, [modal]); + getTeamList(); + }, []); const handlePageChange = async (pageData, items = pagy.items) => { if (pageData == "...") return; @@ -101,14 +99,10 @@ const TeamList = () => { }} > {!hideContainer && ( - +
    -
    -
    - - - +
    { totalPages={pagy?.pages} /> - + )} 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/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"} diff --git a/app/javascript/src/components/TimeTracking/MonthCalender.tsx b/app/javascript/src/components/TimeTracking/MonthCalender.tsx index 724d4ed780..d6ee3a4137 100644 --- a/app/javascript/src/components/TimeTracking/MonthCalender.tsx +++ b/app/javascript/src/components/TimeTracking/MonthCalender.tsx @@ -58,10 +58,13 @@ const MonthCalender = ({ `${currentYear}-${currentMonthNumber + 1}-${i}` ).format("YYYY-MM-DD"); - const totalDuration = entryList[date]?.reduce( - (acc: number, cv: number) => cv["duration"] + acc, - 0 - ); + const totalDuration = + entryList && entryList[date] + ? entryList[date]?.reduce( + (acc: number, cv: number) => cv["duration"] + acc, + 0 + ) + : 0; if (totalDuration) currentWeekTotalHours += totalDuration; weeksData[dayInWeekCounter] = { date, diff --git a/app/javascript/src/components/TimeTracking/index.tsx b/app/javascript/src/components/TimeTracking/index.tsx index 1c0ab8b0b2..ea01ecba06 100644 --- a/app/javascript/src/components/TimeTracking/index.tsx +++ b/app/javascript/src/components/TimeTracking/index.tsx @@ -264,7 +264,7 @@ const TimeTracking: React.FC = ({ user, isAdminUser }) => { const day = dayjs() .weekday(weekCounter + weekDay) .format("YYYY-MM-DD"); - if (entryList[day]) { + if (entryList && entryList[day]) { let dayTotal = 0; entryList[day].forEach(e => { dayTotal += e.duration; @@ -332,7 +332,7 @@ const TimeTracking: React.FC = ({ user, isAdminUser }) => { .weekday(weekDay + weekCounter) .format("YYYY-MM-DD"); - if (!entryList[date]) continue; + if (!entryList || !entryList[date]) continue; entryList[date].forEach(entry => { let entryAdded = false; 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..7d8ccb5dde 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); @@ -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); @@ -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: @@ -360,7 +360,7 @@ const TimesheetEntries = ({ user, isAdminUser }: Iprops) => { const day = dayjs() .weekday(weekCounter + weekDay) .format("YYYY-MM-DD"); - if (entryList[day]) { + if (entryList && entryList[day]) { let dayTotal = 0; entryList[day].forEach(e => { dayTotal += e.duration; @@ -432,7 +432,7 @@ const TimesheetEntries = ({ user, isAdminUser }: Iprops) => { .weekday(weekDay + weekCounter) .format("YYYY-MM-DD"); - if (!entryList[date]) continue; + if (!entryList || !entryList[date]) continue; entryList[date].forEach(entry => { if (entry["type"] == "timesheet") { @@ -549,10 +549,13 @@ const TimesheetEntries = ({ user, isAdminUser }: Iprops) => { `${currentYear}-${currentMonthNumber + 1}-${i}` ).format("YYYY-MM-DD"); - const totalDuration = entryList[date]?.reduce( - (acc: number, cv: number) => cv["duration"] + acc, - 0 - ); + const totalDuration = + entryList && entryList[date] + ? entryList[date]?.reduce( + (acc: number, cv: number) => cv["duration"] + acc, + 0 + ) + : 0; if (totalDuration) currentWeekTotalHours += totalDuration; weeksData[dayInWeekCounter] = { date, @@ -676,7 +679,7 @@ const TimesheetEntries = ({ user, isAdminUser }: Iprops) => {
    {isDesktop && (
    {!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/app/javascript/src/constants/index.tsx b/app/javascript/src/constants/index.tsx index f1e3356244..dea8ce4833 100644 --- a/app/javascript/src/constants/index.tsx +++ b/app/javascript/src/constants/index.tsx @@ -32,6 +32,7 @@ export enum Paths { TIME_TRACKING = "/time-tracking", TEAM = "/team/*", TEAMS = "/teams/*", + EXPENSES = "/expenses", SETTINGS = "/settings/*", AUTHORIZATION = "/authorization", Leave_Management = "/leave-management", diff --git a/app/javascript/src/constants/routes.ts b/app/javascript/src/constants/routes.ts index e2966cc490..ced02d73a3 100644 --- a/app/javascript/src/constants/routes.ts +++ b/app/javascript/src/constants/routes.ts @@ -4,6 +4,8 @@ import EmailVerificationSuccess from "components/Authentication/EmailVerificatio import ForgotPassword from "components/Authentication/ForgotPassword"; import SignIn from "components/Authentication/SignIn"; import SignUp from "components/Authentication/SignUp"; +import ExpenseDetails from "components/Expenses/Details"; +import Expenses from "components/Expenses/List"; import InvoiceEmail from "components/InvoiceEmail"; import InvoicesRouteConfig from "components/Invoices/InvoicesRouteConfig"; import LeaveManagement from "components/LeaveManagement"; @@ -76,6 +78,12 @@ const InvoiceRoutes = [{ path: "*", Component: InvoicesRouteConfig }]; const SettingsRoutes = [{ path: "*", Component: ProfileLayout }]; +const ExpenseRoutes = [ + { path: "", Component: Expenses }, + { path: ":expenseId", Component: ExpenseDetails }, + { path: "*", Component: ErrorPage }, +]; + const { ADMIN, OWNER, BOOK_KEEPER, EMPLOYEE, CLIENT } = Roles; export const AUTH_ROUTES = [ @@ -171,6 +179,11 @@ export const ROUTES = [ subRoutes: SettingsRoutes, authorisedRoles: [ADMIN, OWNER, EMPLOYEE, BOOK_KEEPER, CLIENT], }, + { + path: Paths.EXPENSES, + subRoutes: ExpenseRoutes, + authorisedRoles: [ADMIN, OWNER, BOOK_KEEPER], + }, { path: Paths.Leave_Management, subRoutes: LeaveManagementRoutes, 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/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/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/miruIcons/index.ts b/app/javascript/src/miruIcons/index.ts index cc0270a9dc..744ab1b0b5 100644 --- a/app/javascript/src/miruIcons/index.ts +++ b/app/javascript/src/miruIcons/index.ts @@ -28,6 +28,7 @@ import { Pen, FileCsv, FilePdf, + File, Printer, Share, DownloadSimple, @@ -53,6 +54,14 @@ import { Cake, DeviceMobileCamera, ArrowCounterClockwise, + Coins, + Percent, + House, + ShieldCheck, + Car, + Wrench, + Armchair, + ForkKnife, } from "phosphor-react"; const error404Animation = require("./GIFS/404_animation.gif"); @@ -65,7 +74,10 @@ 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"); const hoursIcon = require("./svgIcons/Hours.svg"); const hoursHoverIcon = require("./svgIcons/HoursHover.svg"); @@ -181,6 +193,7 @@ export const XIcon = X; export const CaretDownIcon = CaretDown; export const CaretUpIcon = CaretUp; export const PenIcon = Pen; +export const FileIcon = File; export const FileCsvIcon = FileCsv; export const FilePdfIcon = FilePdf; export const PrinterIcon = Printer; @@ -206,6 +219,14 @@ 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; +export const CarIcon = Car; +export const FurnitureIcon = Armchair; +export const WrenchIcon = Wrench; +export const ShieldIcon = ShieldCheck; // custom svg icons export const WarningTriangleSVG = warningTriangle; @@ -303,3 +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 @@ + + + + + diff --git a/app/javascript/src/miruIcons/svgIcons/expenseIcon.svg b/app/javascript/src/miruIcons/svgIcons/expenseIcon.svg new file mode 100644 index 0000000000..babf2a750d --- /dev/null +++ b/app/javascript/src/miruIcons/svgIcons/expenseIcon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + 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(); 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/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/models/holiday.rb b/app/models/holiday.rb index adb57b6dd6..089a2a62d1 100644 --- a/app/models/holiday.rb +++ b/app/models/holiday.rb @@ -27,16 +27,26 @@ class Holiday < ApplicationRecord include Discard::Model - has_many :holiday_infos, dependent: :destroy + enum time_period_optional_holidays: { per_quarter: 0, per_year: 1, per_month: 2, per_week: 3 } + belongs_to :company - enum time_period_optional_holidays: { per_quarter: 0, per_year: 1, per_month: 2, per_week: 3 } + has_many :holiday_infos, dependent: :destroy + has_many :timeoff_entries, through: :holiday_infos validates :year, presence: true, uniqueness: { scope: :company_id } validates :year, numericality: { only_integer: true, greater_than_or_equal_to: 1900, less_than_or_equal_to: 2099 } after_discard :discard_holiday_infos + def national_timeoff_entries + timeoff_entries.kept.joins(:holiday_info).where(holiday_infos: { category: :national }) + end + + def optional_timeoff_entries + timeoff_entries.kept.joins(:holiday_info).where(holiday_infos: { category: :optional }) + end + private def discard_holiday_infos diff --git a/app/models/holiday_info.rb b/app/models/holiday_info.rb index 60677ffb4b..dcd55ded61 100644 --- a/app/models/holiday_info.rb +++ b/app/models/holiday_info.rb @@ -35,7 +35,6 @@ class HolidayInfo < ApplicationRecord format: { with: /\A[a-zA-Z\s]+\z/ }, length: { maximum: 30 } validates :date, :category, presence: true - validate :validate_optional_holidays, if: -> { category == "optional" } validate :validate_holiday_category validate :validate_year @@ -49,12 +48,6 @@ class HolidayInfo < ApplicationRecord private - def validate_optional_holidays - unless holiday&.enable_optional_holidays - errors.add(:base, "optional holidays are disabled") - end - end - def validate_holiday_category unless holiday&.holiday_types&.include?(category) errors.add(:category, "must be a valid holiday category for the associated holiday") 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/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/app/models/timeoff_entry.rb b/app/models/timeoff_entry.rb index 2ea4511c53..64be596ff1 100644 --- a/app/models/timeoff_entry.rb +++ b/app/models/timeoff_entry.rb @@ -41,6 +41,8 @@ class TimeoffEntry < ApplicationRecord validates :leave_date, presence: true validate :either_leave_type_or_holiday_info_present + validate :allow_one_holiday_per_day + validate :optional_holiday_timeoff_entry scope :during, -> (from, to) { where(leave_date: from..to).order(leave_date: :desc) } @@ -57,4 +59,58 @@ def either_leave_type_or_holiday_info_present errors.add(:base, "Choose either leave type or holiday info, not both") end end + + def optional_holiday_timeoff_entry + return unless holiday_info.present? + + return unless holiday_info&.category == "optional" && + holiday&.no_of_allowed_optional_holidays.present? + + no_of_allowed_optional_holidays = holiday.no_of_allowed_optional_holidays + time_period_optional_holidays = holiday.time_period_optional_holidays + optional_timeoff_entries = holiday.optional_timeoff_entries + + error_message = "You have exceeded the maximum number of permitted optional holidays" + case time_period_optional_holidays.to_sym + when :per_week + start_of_week = leave_date.beginning_of_week + end_of_week = leave_date.end_of_week + + total_optional_entries = optional_timeoff_entries.where( + leave_date: start_of_week..end_of_week, + user:).count + when :per_month + start_of_month = leave_date.beginning_of_month + end_of_month = leave_date.end_of_month + + total_optional_entries = optional_timeoff_entries.where( + leave_date: start_of_month..end_of_month, + user:).count + when :per_quarter + start_of_quarter = leave_date.beginning_of_quarter + end_of_quarter = leave_date.end_of_quarter + + total_optional_entries = optional_timeoff_entries.where( + leave_date: start_of_quarter..end_of_quarter, + user:).count + when :per_year + total_optional_entries = optional_timeoff_entries.where(user:).count + else + errors.add(:base, error_message) + end + + if total_optional_entries >= no_of_allowed_optional_holidays + errors.add(:base, error_message) + end + end + + def allow_one_holiday_per_day + return unless holiday_info.present? + + optional_timeoff_entries = holiday.optional_timeoff_entries.where(leave_date:, user:).exists? + national_timeoff_entries = holiday.national_timeoff_entries.where(leave_date:, user:).exists? + if optional_timeoff_entries || national_timeoff_entries + errors.add(:base, "You are adding two holidays on the same day, please recheck") + end + end end diff --git a/app/models/timesheet_entry.rb b/app/models/timesheet_entry.rb index daf0fe94b0..895c9f6361 100644 --- a/app/models/timesheet_entry.rb +++ b/app/models/timesheet_entry.rb @@ -44,6 +44,7 @@ class TimesheetEntry < ApplicationRecord validates :duration, :work_date, :bill_status, presence: true validates :duration, numericality: { less_than_or_equal_to: 6000000, greater_than_or_equal_to: 0.0 } + validate :validate_billable_project scope :in_workspace, -> (company) { where(project_id: company&.project_ids) } scope :during, -> (from, to) { where(work_date: from..to).order(work_date: :desc) } @@ -122,4 +123,10 @@ def ensure_billed_status_should_not_be_changed errors.add(:timesheet_entry, I18n.t(:errors)[:bill_status_billed]) if self.bill_status_changed? && self.bill_status_was == "billed" && Current.user.primary_role(Current.company) == "employee" end + + def validate_billable_project + if !project&.billable && bill_status == "unbilled" + errors.add(:base, I18n.t("errors.validate_billable_project")) + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 22f0d9f6de..8ff3fe20e8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -141,6 +141,11 @@ def active_for_authentication? super and self.kept? and (!self.employments.kept.empty? or self.companies.empty?) end + def joined_date_for_company(company) + employment = employments.find_by(company:) + employment&.joined_at + end + def inactive_message if self.employments.kept.empty? && self.kept? I18n.t("user.login.failure.disabled") 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/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/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 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:, 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 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/services/time_tracking_index_service.rb b/app/services/time_tracking_index_service.rb index 52d5f0e889..59f369fd62 100644 --- a/app/services/time_tracking_index_service.rb +++ b/app/services/time_tracking_index_service.rb @@ -93,11 +93,12 @@ def group_timeoff_entries_by_leave_date def leave_types leave = current_company.leaves.find_by(year:) - leave&.leave_types&.kept || [] + leave&.leave_types&.kept end def holiday_infos holiday = current_company.holidays.find_by(year:) - holiday&.holiday_infos&.kept || [] + all_holidays = holiday&.holiday_infos&.kept + holiday.enable_optional_holidays ? all_holidays : all_holidays.national end end diff --git a/app/services/timeoff_entries/index_service.rb b/app/services/timeoff_entries/index_service.rb index 69899028dc..69cd07d30d 100644 --- a/app/services/timeoff_entries/index_service.rb +++ b/app/services/timeoff_entries/index_service.rb @@ -4,6 +4,11 @@ module TimeoffEntries class IndexService < ApplicationService attr_reader :current_company, :current_user, :user_id, :year + CURRENT_DATE = DateTime.now + CURRENT_YEAR = CURRENT_DATE.year + CURRENT_MONTH = CURRENT_DATE.month + CURRENT_WEEK = CURRENT_DATE.cweek + def initialize(current_user, current_company, user_id, year) @current_user = current_user @current_company = current_company @@ -96,25 +101,25 @@ def calculate_total_duration(leave_type) total_duration = case allocation_frequency.to_sym when :per_week - allocation_value * weeks_per_month * months_per_year + calculate_days_per_week_leave_allocation(user_joined_date, allocation_value) when :per_month case allocation_period.to_sym when :days - allocation_value * months_per_year + calculate_days_per_month_leave_allocation(user_joined_date, allocation_value) when :weeks allocation_value * days_per_week * months_per_year end when :per_quarter case allocation_period.to_sym when :days - allocation_value * quarters_per_year + calculate_days_per_quarter_leave_allocation(user_joined_date, allocation_value) when :weeks allocation_value * days_per_week * quarters_per_year end when :per_year case allocation_period.to_sym when :days - allocation_value + calculate_days_per_year_leave_allocation(user_joined_date, allocation_value) when :weeks allocation_value * days_per_week when :months @@ -126,6 +131,66 @@ def calculate_total_duration(leave_type) total_duration end + def calculate_days_per_month_leave_allocation(joining_date, allocation_value) + first_month_allocation_value = allocation_value + if joining_date && joining_date.year == CURRENT_YEAR + total_month_duration = CURRENT_MONTH - joining_date.month + first_month_allocation_value /= 2 if joining_date.day > 15 + total_month_duration.zero? ? first_month_allocation_value + : first_month_allocation_value + allocation_value * total_month_duration + else + first_month_allocation_value * CURRENT_MONTH + end + end + + def calculate_days_per_week_leave_allocation(joining_date, allocation_value) + if joining_date && joining_date.year == CURRENT_YEAR + total_weeks = (CURRENT_WEEK - joining_date.cweek) + first_week_allocation_value = (joining_date.wday >= 3 && joining_date.wday <= 5) ? + (allocation_value / 2) : allocation_value + first_week_allocation_value + allocation_value * total_weeks + else + allocation_value * CURRENT_WEEK + end + end + + def calculate_days_per_quarter_leave_allocation(joining_date, allocation_value) + current_quarter = quarter_position_and_after_mid_quarter(CURRENT_DATE) + joining_date_quarter, is_joining_date_after_mid_quarter = quarter_position_and_after_mid_quarter(joining_date) + first_week_allocation_value = allocation_value + + if joining_date && joining_date.year == CURRENT_YEAR + total_quarter = current_quarter[0] - joining_date_quarter + first_week_allocation_value = allocation_value /= 2 if is_joining_date_after_mid_quarter + first_week_allocation_value + allocation_value * total_quarter + else + allocation_value * current_quarter[0] + end + end + + def calculate_days_per_year_leave_allocation(joining_date, allocation_value) + if joining_date && joining_date.year == CURRENT_YEAR + joining_date.month > CURRENT_MONTH / 2 ? allocation_value / 2 : allocation_value + else + allocation_value + end + end + + def user_joined_date + employee_id = is_admin? ? user_id : current_user.id + user = User.find(employee_id) + user.joined_date_for_company(current_company) + end + + def quarter_position_and_after_mid_quarter(date) + quarter = (date.month / 3.0).ceil + mid_date = Date.new(date.year, (((quarter - 1) * 3) + 2), 15) + + after_mid_quarter = date > mid_date + + [quarter, after_mid_quarter] + end + def calculate_previous_year_carryforward(leave_type) return 0 unless leave_type diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index 0420fd15b7..cfdeb9f661 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -1,13 +1,22 @@ -

    Hello <%= @resource.first_name %>,

    -

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

    +<%= stylesheet_link_tag "application" %> +
    +
    + <%= 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

    -

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

    +

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

    +

    <%= @resource.email %>.

    +

    Please use the following link to reset your password

    -

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

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

    -

    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.

    +

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

    +
    -

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

    + <%= render "devise/shared/footer" %> +
    diff --git a/app/views/devise/shared/_footer.html.erb b/app/views/devise/shared/_footer.html.erb new file mode 100644 index 0000000000..b4745af509 --- /dev/null +++ b/app/views/devise/shared/_footer.html.erb @@ -0,0 +1,56 @@ +
    + + + + +
    Powered by + <%= image_tag "miruLogoWithText.svg", height: '48px', width: '120px', alt: 'Miru logo' %> +
    +

    + Saeloun Inc, 475 Clermont Avenue, Brooklyn, New York 11238, United States +

    +
    + +
    +
    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/app/views/internal_api/v1/holidays/index.json.jbuilder b/app/views/internal_api/v1/holidays/index.json.jbuilder index 10f528f913..44bae6ab63 100644 --- a/app/views/internal_api/v1/holidays/index.json.jbuilder +++ b/app/views/internal_api/v1/holidays/index.json.jbuilder @@ -8,14 +8,14 @@ json.holidays holidays do |holiday| json.holiday_types holiday.holiday_types json.time_period_optional_holidays holiday.time_period_optional_holidays - json.national_holidays holiday.holiday_infos.national.each do |holiday_info| + json.national_holidays holiday.holiday_infos.kept.national.each do |holiday_info| json.id holiday_info.id json.date CompanyDateFormattingService.new(holiday_info.date, company: current_company).process json.name holiday_info.name json.category holiday_info.category end - json.optional_holidays holiday.holiday_infos.optional.each do |holiday_info| + json.optional_holidays holiday.holiday_infos.kept.optional.each do |holiday_info| json.id holiday_info.id json.date CompanyDateFormattingService.new(holiday_info.date, company: current_company).process json.name holiday_info.name 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/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 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/locales/en.yml b/config/locales/en.yml index 043fb06b16..c7946d4f1d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -271,6 +271,7 @@ en: Only owner or admin users are authorized to perform this action bill_status_billed: You can't bill an entry that has already been billed create_billed_entry: You can't create a billed timesheet entry + validate_billable_project: You can't create an unbilled entry for non-billable projects internal_server_error: Something went wrong user_already_member: Email id is already in use. Please enter another email id updation_not_allowed: updation is not allowed @@ -292,3 +293,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..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] @@ -141,7 +145,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 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/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" 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/holiday_info_spec.rb b/spec/models/holiday_info_spec.rb index 967cdf40f1..3e18dd720b 100644 --- a/spec/models/holiday_info_spec.rb +++ b/spec/models/holiday_info_spec.rb @@ -23,12 +23,6 @@ it { is_expected.to validate_presence_of(:date) } it { is_expected.to validate_presence_of(:category) } - it 'validates optional holidays when the category is "optional"' do - optional_holiday_info.holiday.enable_optional_holidays = false - optional_holiday_info.valid? - expect(optional_holiday_info.errors[:base]).to include("optional holidays are disabled") - end - it "validates the year of the date" do invalid_holiday_info = build(:holiday_info, holiday:, date: "2022-01-01") invalid_holiday_info.valid? 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/models/timeoff_entry_spec.rb b/spec/models/timeoff_entry_spec.rb index e05fa7f335..8d326256cc 100644 --- a/spec/models/timeoff_entry_spec.rb +++ b/spec/models/timeoff_entry_spec.rb @@ -3,7 +3,42 @@ require "rails_helper" RSpec.describe TimeoffEntry, type: :model do + let(:company) { create(:company) } + let(:user) { create(:user, current_workspace_id: company.id) } + let(:holiday) { create(:holiday, year: Date.current.year, company:) } + let(:national_holiday) { create(:holiday_info, date: Date.current, category: "national", holiday:) } + let(:optional_holiday) { create(:holiday_info, date: Date.current, category: "optional", holiday:) } + describe "validations" do + before do + @existing_timeoff_entry = create( + :timeoff_entry, + user_id: user.id, + leave_type_id: nil, + holiday_info_id: optional_holiday.id, + duration: 400, + leave_date: Date.current + ) + + @new_time_off_entry = build( + :timeoff_entry, + user_id: user.id, + leave_type_id: nil, + holiday_info_id: optional_holiday.id, + duration: 400, + leave_date: Date.current, + ) + + @new_optional_time_off_entry = build( + :timeoff_entry, + user_id: user.id, + leave_type_id: nil, + holiday_info_id: optional_holiday.id, + duration: 400, + leave_date: Date.current + 1, + ) + end + it { is_expected.to validate_presence_of(:duration) } it do @@ -13,6 +48,18 @@ end it { is_expected.to validate_presence_of(:leave_date) } + + it "is not valid if adding two holidays on the same day" do + expect(@new_time_off_entry).not_to be_valid + expect(@new_time_off_entry.errors[:base]).to include( + "You are adding two holidays on the same day, please recheck") + end + + it "is not valid if it exceeds the maximum number of permitted optional holidays" do + expect(@new_optional_time_off_entry).not_to be_valid + expect(@new_optional_time_off_entry.errors[:base]).to include( + "You have exceeded the maximum number of permitted optional holidays") + end end describe "associations" do diff --git a/spec/models/timesheet_entry_spec.rb b/spec/models/timesheet_entry_spec.rb index bef2f0983f..de5ac559a1 100644 --- a/spec/models/timesheet_entry_spec.rb +++ b/spec/models/timesheet_entry_spec.rb @@ -9,6 +9,7 @@ let(:client2) { create(:client, company_id: company2.id) } let(:project) { create(:project, client_id: client.id) } let(:project2) { create(:project, client_id: client2.id) } + let(:billable_project) { create(:project, billable: true, client_id: client.id) } let(:timesheet_entry) { create(:timesheet_entry, project:) } describe "Associations" do @@ -97,6 +98,26 @@ end end + describe "#validate_billable_project" do + let(:error_message) { "You can't create an unbilled entry for non-billable projects" } + + context "when the project is non-billable" do + it "returns an error if project is non billable and user try to create unbilled entry" do + timesheet_entry = build(:timesheet_entry, bill_status: "unbilled") + expect(timesheet_entry.valid?).to be_falsey + expect(timesheet_entry.errors.messages[:base]).to include(error_message) + end + end + + context "when the project is billable" do + it "does not return an error if the user tries to create an unbilled entry" do + timesheet_entry = build(:timesheet_entry, bill_status: "unbilled", project: billable_project) + expect(timesheet_entry.valid?).to be_truthy + expect(timesheet_entry.errors.messages[:base]).not_to include(error_message) + end + end + end + describe "#ensure_billed_status_should_not_be_changed" do context "when admin or owner is updating the time entry" do before do @@ -106,7 +127,7 @@ end let(:admin) { create(:user) } - let(:timesheet_entry) { create(:timesheet_entry, project:) } + let(:timesheet_entry) { create(:timesheet_entry, project: billable_project) } context "when time entry is billed" do it "allows owners and admins to edit the billed time entry" do @@ -160,7 +181,7 @@ end let(:user) { create(:user) } - let(:timesheet_entry) { create(:timesheet_entry, project:) } + let(:timesheet_entry) { create(:timesheet_entry, project: billable_project) } let(:error_message) { "You can't bill an entry that has already been billed" } context "when time entry is billed" do 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/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) diff --git a/spec/requests/internal_api/v1/generate_invoice/index_spec.rb b/spec/requests/internal_api/v1/generate_invoice/index_spec.rb index 9802e53127..9315fad64e 100644 --- a/spec/requests/internal_api/v1/generate_invoice/index_spec.rb +++ b/spec/requests/internal_api/v1/generate_invoice/index_spec.rb @@ -6,7 +6,7 @@ let(:company) { create(:company) } let(:user) { create(:user, current_workspace_id: company.id) } let(:client) { create(:client, company:) } - let!(:project) { create(:project, client:) } + let!(:project) { create(:project, billable: true, client:) } let!(:project_member) { create(:project_member, user:, project:) } let!(:timesheet_entry) { create(:timesheet_entry, user:, project:, bill_status: "unbilled") } 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) diff --git a/spec/requests/internal_api/v1/reports/time_entry/index_spec.rb b/spec/requests/internal_api/v1/reports/time_entry/index_spec.rb index 150bb000d2..2c4290708d 100644 --- a/spec/requests/internal_api/v1/reports/time_entry/index_spec.rb +++ b/spec/requests/internal_api/v1/reports/time_entry/index_spec.rb @@ -6,9 +6,9 @@ let(:company) { create(:company, name: "company_one") } let(:user) { create(:user, current_workspace_id: company.id) } let(:client) { create(:client, :with_logo, company:, name: "American_Client") } - let(:project) { create(:project, client:, name: "A class project") } + let(:project) { create(:project, billable: true, client:, name: "A class project") } let(:client2) { create(:client, company_id: company.id, name: "European_Client") } - let(:project2) { create(:project, client_id: client2.id, name: "B class project") } + let(:project2) { create(:project, billable: true, client_id: client2.id, name: "B class project") } let(:client3) { create(:client, company_id: company.id, name: "Indian_Client") } let(:project3) { create(:project, client_id: client3.id, name: "C class project") } let(:last_month_start_date) { 1.month.ago.beginning_of_month + 1.days } diff --git a/spec/requests/internal_api/v1/time_tracking/index_spec.rb b/spec/requests/internal_api/v1/time_tracking/index_spec.rb index 2827ed0c30..628555627a 100644 --- a/spec/requests/internal_api/v1/time_tracking/index_spec.rb +++ b/spec/requests/internal_api/v1/time_tracking/index_spec.rb @@ -13,6 +13,9 @@ let!(:project2) { create(:project, client: client1) } let!(:project3) { create(:project, client: client2) } let!(:project4) { create(:project, client: client3) } + let!(:holiday) { create(:holiday, year: Date.current.year, company: company1) } + let(:national_holiday) { create(:holiday_info, date: Date.current, category: "national", holiday:) } + let(:optional_holiday) { create(:holiday_info, date: Date.current + 2.days, category: "optional", holiday:) } before do create(:project_member, user:, project: project1) 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 diff --git a/spec/requests/internal_api/v1/timesheet_entries/create_spec.rb b/spec/requests/internal_api/v1/timesheet_entries/create_spec.rb index 4098ecb7c2..30177a30cf 100644 --- a/spec/requests/internal_api/v1/timesheet_entries/create_spec.rb +++ b/spec/requests/internal_api/v1/timesheet_entries/create_spec.rb @@ -6,7 +6,7 @@ let(:company) { create(:company) } let(:user) { create(:user, current_workspace_id: company.id) } let(:client) { create(:client, company:) } - let(:project) { create(:project, client:) } + let(:project) { create(:project, billable: true, client:) } context "when user is an admin" do before do diff --git a/spec/requests/internal_api/v1/timesheet_entries/update_spec.rb b/spec/requests/internal_api/v1/timesheet_entries/update_spec.rb index 9f935c1b44..d4d581e8b6 100644 --- a/spec/requests/internal_api/v1/timesheet_entries/update_spec.rb +++ b/spec/requests/internal_api/v1/timesheet_entries/update_spec.rb @@ -7,7 +7,7 @@ let(:user) { create(:user, current_workspace_id: company.id) } let(:user2) { create(:user, current_workspace_id: company.id) } let(:client) { create(:client, company:) } - let(:project) { create(:project, client:) } + let(:project) { create(:project, billable: true, client:) } let!(:timesheet_entry) { create( :timesheet_entry, 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 diff --git a/spec/services/timeoff_entries/index_service_spec.rb b/spec/services/timeoff_entries/index_service_spec.rb new file mode 100644 index 0000000000..579b8738ba --- /dev/null +++ b/spec/services/timeoff_entries/index_service_spec.rb @@ -0,0 +1,304 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe TimeoffEntries::IndexService do # rubocop:disable RSpec/FilePath + let(:company) { create(:company) } + let(:user) { create(:user, current_workspace_id: company.id) } + let!(:leave) { create(:leave, company:, year: Date.today.year) } + let!(:leave_type) { + create( + :leave_type, + name: "Annual", + allocation_value: 2, + icon: LeaveType.icons[:calendar], + color: LeaveType.colors[:chart_blue], + allocation_period: :days, + allocation_frequency: :per_month, + carry_forward_days: 5, + leave: + ) + } + let!(:leave_type_days_per_week) { + create( + :leave_type, + name: "Annual", + icon: LeaveType.icons[:cake], + color: LeaveType.colors[:chart_pink], + allocation_value: 2, + allocation_period: :days, + allocation_frequency: :per_week, + carry_forward_days: 2, + leave: + ) + } + + let!(:leave_type_days_per_quarter) { + create( + :leave_type, + name: "Annual", + icon: LeaveType.icons[:car], + color: LeaveType.colors[:chart_green], + allocation_value: 2, + allocation_period: :days, + allocation_frequency: :per_quarter, + carry_forward_days: 2, + leave: + ) + } + + let!(:leave_type_days_per_year) { + create( + :leave_type, + name: "Annual", + icon: LeaveType.icons[:medicine], + color: LeaveType.colors[:chart_orange], + allocation_value: 2, + allocation_period: :days, + allocation_frequency: :per_year, + carry_forward_days: 2, + leave: + ) + } + + let!(:timeoff_entry) { # rubocop:disable RSpec/LetSetup + create( + :timeoff_entry, + duration: 60, + leave_date: Date.today, + user:, + leave_type:) + } + + describe "#initialize" do + it "checks preset values in initialize method" do + params = { + user_id: user.id, + year: Date.today.year + } + + service = TimeoffEntries::IndexService.new(user, company, params[:user_id], params[:year]) + + expect(service.current_user.present?).to be true + expect(service.current_company.present?).to be true + expect(service.user_id.present?).to be true + expect(service.year.present?).to be true + end + end + + describe "#process" do + before do + create(:employment, company:, user:) + user.add_role :admin, company + + params = { + user_id: user.id, + year: Date.today.year + } + + service = TimeoffEntries::IndexService.new(user, company, params[:user_id], params[:year]) + @data = service.process + end + + it "checks for all timeoff entries of current user" do + timeoff_entries ||= company.timeoff_entries.includes([:leave_type]) + .where(user_id: user.id) + .order(leave_date: :desc) + + expect(@data[:timeoff_entries]).to eq(timeoff_entries) + end + + it "returns all the employees if the current user is admin" do + expect(@data[:employees]).to eq(company.users) + end + + it "returns total timeoff entries duration" do + timeoff_entries_duration ||= company.timeoff_entries.includes([:leave_type]) + .where(user_id: user.id) + .order(leave_date: :desc) + .sum(:duration) + + expect(@data[:total_timeoff_entries_duration]).to eq(timeoff_entries_duration) + end + + it "returns leave balance for days per month" do + summary_object = { + id: leave_type.id, + name: leave_type.name, + icon: leave_type.icon, + color: leave_type.color, + total_leave_type_days: 6, + timeoff_entries_duration: 60, + net_duration: 2820, + net_days: 5 + } + + expect(@data[:leave_balance][0]).to eq(summary_object) + end + + it "returns leave balance for days per week" do + summary_object = { + id: leave_type_days_per_week.id, + name: leave_type_days_per_week.name, + icon: leave_type_days_per_week.icon, + color: leave_type_days_per_week.color, + total_leave_type_days: 24, + timeoff_entries_duration: 0, + net_duration: 11520, + net_days: 24 + } + + expect(@data[:leave_balance][1]).to eq(summary_object) + end + + it "returns leave balance for days per quarter" do + summary_object = { + id: leave_type_days_per_quarter.id, + name: leave_type_days_per_quarter.name, + icon: leave_type_days_per_quarter.icon, + color: leave_type_days_per_quarter.color, + total_leave_type_days: 2, + timeoff_entries_duration: 0, + net_duration: 960, + net_days: 2 + } + + expect(@data[:leave_balance][2]).to eq(summary_object) + end + + it "returns leave balance for days per year" do + summary_object = { + id: leave_type_days_per_year.id, + name: leave_type_days_per_year.name, + icon: leave_type_days_per_year.icon, + color: leave_type_days_per_year.color, + total_leave_type_days: 2, + timeoff_entries_duration: 0, + net_duration: 960, + net_days: 2 + } + + expect(@data[:leave_balance][3]).to eq(summary_object) + end + end + + describe "#process days per month when joining date is current year" do + before do + create(:employment, company:, user:, joined_at: Date.new(Time.current.year, 1, 16), resigned_at: nil) + user.add_role :admin, company + + params = { + user_id: user.id, + year: Date.today.year + } + + service = TimeoffEntries::IndexService.new(user, company, params[:user_id], params[:year]) + @data = service.process + end + + it "returns leave balance for days per month" do + summary_object = { + id: leave_type.id, + name: leave_type.name, + icon: leave_type.icon, + color: leave_type.color, + total_leave_type_days: 5, + timeoff_entries_duration: 60, + net_duration: 2340, + net_days: 4 + } + + expect(@data[:leave_balance][0]).to eq(summary_object) + end + end + + describe "#process days per week when joining date is current year" do + before do + create(:employment, company:, user:, joined_at: Date.new(Time.current.year, 1, 5), resigned_at: nil) + user.add_role :admin, company + + params = { + user_id: user.id, + year: Date.today.year + } + + service = TimeoffEntries::IndexService.new(user, company, params[:user_id], params[:year]) + @data = service.process + end + + it "returns leave balance for days per week" do + summary_object = { + id: leave_type_days_per_week.id, + name: leave_type_days_per_week.name, + icon: leave_type_days_per_week.icon, + color: leave_type_days_per_week.color, + total_leave_type_days: 23, + timeoff_entries_duration: 0, + net_duration: 11040, + net_days: 23 + } + + expect(@data[:leave_balance][1]).to eq(summary_object) + end + end + + describe "#process days per quarter when joining date is current year" do + before do + create(:employment, company:, user:, joined_at: Date.new(Time.current.year, 2, 17), resigned_at: nil) + user.add_role :admin, company + + params = { + user_id: user.id, + year: Date.today.year + } + + service = TimeoffEntries::IndexService.new(user, company, params[:user_id], params[:year]) + @data = service.process + end + + it "returns leave balance for days per quarter" do + summary_object = { + id: leave_type_days_per_quarter.id, + name: leave_type_days_per_quarter.name, + icon: leave_type_days_per_quarter.icon, + color: leave_type_days_per_quarter.color, + total_leave_type_days: 1, + timeoff_entries_duration: 0, + net_duration: 480, + net_days: 1 + } + + expect(@data[:leave_balance][2]).to eq(summary_object) + end + end + + describe "#process days per year when joining date is current year" do + before do + create(:employment, company:, user:, joined_at: Date.new(Time.current.year, 1, 5), resigned_at: nil) + user.add_role :admin, company + + params = { + user_id: user.id, + year: Date.today.year + } + + service = TimeoffEntries::IndexService.new(user, company, params[:user_id], params[:year]) + @data = service.process + end + + it "returns leave balance for days per year" do + summary_object = { + id: leave_type_days_per_year.id, + name: leave_type_days_per_year.name, + icon: leave_type_days_per_year.icon, + color: leave_type_days_per_year.color, + total_leave_type_days: 2, + timeoff_entries_duration: 0, + net_duration: 960, + net_days: 2 + } + + expect(@data[:leave_balance][3]).to eq(summary_object) + end + end +end diff --git a/spec/services/timeoff_entries/index_service_spec.rb.rb b/spec/services/timeoff_entries/index_service_spec.rb.rb deleted file mode 100644 index e8f26bd354..0000000000 --- a/spec/services/timeoff_entries/index_service_spec.rb.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe TimeoffEntries::IndexService do # rubocop:disable RSpec/FilePath - let(:company) { create(:company) } - let(:user) { create(:user, current_workspace_id: company.id) } - let!(:leave) { create(:leave, company:, year: Date.today.year) } - let!(:leave_type) { - create( - :leave_type, - name: "Annual", - allocation_value: 2, - allocation_period: :days, - allocation_frequency: :per_month, - carry_forward_days: 5, - leave: - ) - } - - let!(:timeoff_entry) { # rubocop:disable RSpec/LetSetup - create( - :timeoff_entry, - duration: 60, - leave_date: Date.today, - user:, - leave_type:) - } - - describe "#initialize" do - it "checks preset values in initialize method" do - params = { - user_id: user.id, - year: Date.today.year - } - - service = TimeoffEntries::IndexService.new(params, company, user) - - expect(service.params.present?).to be true - expect(service.current_company.present?).to be true - expect(service.current_user.present?).to be true - end - end - - describe "#process" do - before do - create(:employment, company:, user:) - user.add_role :admin, company - - params = { - user_id: user.id, - year: Date.today.year - } - - service = TimeoffEntries::IndexService.new(params, company, user) - @data = service.process - end - - it "checks for all timeoff entries of current user" do - timeoff_entries ||= company.timeoff_entries.includes([:leave_type]) - .where(user_id: user.id) - .order(leave_date: :desc) - - expect(@data[:timeoff_entries]).to eq(timeoff_entries) - end - - it "returns all the employees if the current user is admin" do - expect(@data[:employees]).to eq(company.users) - end - - it "returns total timeoff entries duration" do - timeoff_entries_duration ||= company.timeoff_entries.includes([:leave_type]) - .where(user_id: user.id) - .order(leave_date: :desc) - .sum(:duration) - - expect(@data[:total_timeoff_entries_duration]).to eq(timeoff_entries_duration) - end - - it "returns leave balance" do - summary_object = { - id: leave_type.id, - name: leave_type.name, - icon: leave_type.icon, - color: leave_type.color, - total_leave_type_days: 24, - timeoff_entries_duration: 60, - net_duration: 11460, - net_days: 11460 / 60 / 8 - } - - expect(@data[:leave_balance][0]).to eq(summary_object) - end - end -end 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", 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"