From b4364f79fd7d7d37500df6eca563315d9c373a5d Mon Sep 17 00:00:00 2001 From: Apoorv Tiwari Date: Thu, 7 Mar 2024 20:49:29 +0530 Subject: [PATCH 1/2] Remove cursor pointer from summary (#1690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove cursor pointer from summary Co-authored-by: “Apoorv <“tiwari.apoorv1316@gmail.com”> --- .../Reports/OutstandingInvoiceReport/Container/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/src/components/Reports/OutstandingInvoiceReport/Container/index.tsx b/app/javascript/src/components/Reports/OutstandingInvoiceReport/Container/index.tsx index a117d85d9c..7d6abdd40e 100644 --- a/app/javascript/src/components/Reports/OutstandingInvoiceReport/Container/index.tsx +++ b/app/javascript/src/components/Reports/OutstandingInvoiceReport/Container/index.tsx @@ -43,6 +43,7 @@ const Container = () => {
From c8f6fb9a10343cb55b52f62bbb2d2de638c60d33 Mon Sep 17 00:00:00 2001 From: Shruti-Apte <72149587+Shruti-Apte@users.noreply.github.com> Date: Thu, 7 Mar 2024 22:46:33 -0800 Subject: [PATCH 2/2] Expense module: UI and Integration (#1625) * Basic structure and header * Table structure added * expense module UI and Integration * edit and delete API and integration * review comments and few icon changes * removed chart.js * worked on review commets * worked on review comments * before_actions updated --------- Co-authored-by: Saeloun --- .../internal_api/v1/expenses_controller.rb | 18 +- app/javascript/src/apis/expenses.ts | 27 ++ .../common/CustomCreatableSelect/index.tsx | 138 ++++++++ .../Mobile/AddEditModalHeader/index.tsx | 14 + .../components/Expenses/Details/Expense.tsx | 60 ++++ .../components/Expenses/Details/Header.tsx | 64 ++++ .../src/components/Expenses/Details/index.tsx | 122 +++++++ .../List/Container/ExpensesSummary.tsx | 36 ++ .../List/Container/Table/MoreOptions.tsx | 63 ++++ .../List/Container/Table/TableHeader.tsx | 54 +++ .../List/Container/Table/TableRow.tsx | 110 ++++++ .../Expenses/List/Container/Table/index.tsx | 27 ++ .../Expenses/List/Container/index.tsx | 24 ++ .../src/components/Expenses/List/Header.tsx | 53 +++ .../src/components/Expenses/List/index.tsx | 81 +++++ .../Expenses/Modals/AddExpenseModal.tsx | 35 ++ .../Expenses/Modals/DeleteExpenseModal.tsx | 45 +++ .../Expenses/Modals/EditExpenseModal.tsx | 37 ++ .../Expenses/Modals/ExpenseForm.tsx | 332 ++++++++++++++++++ .../Expenses/Modals/Mobile/AddExpense.tsx | 27 ++ .../Expenses/Modals/Mobile/EditExpense.tsx | 29 ++ .../src/components/Expenses/utils.js | 114 ++++++ .../components/Invoices/List/MoreOptions.tsx | 2 +- .../src/components/Navbar/utils.tsx | 19 + .../Organization/Holidays/HolidaysModal.tsx | 2 +- app/javascript/src/constants/index.tsx | 1 + app/javascript/src/constants/routes.ts | 13 + app/javascript/src/miruIcons/index.ts | 18 + .../src/miruIcons/svgIcons/expenseIcon.svg | 18 + app/models/expense_category.rb | 2 +- app/policies/expense_policy.rb | 8 + config/locales/en.yml | 3 + config/routes/internal_api.rb | 2 +- 33 files changed, 1593 insertions(+), 5 deletions(-) create mode 100644 app/javascript/src/apis/expenses.ts create mode 100644 app/javascript/src/common/CustomCreatableSelect/index.tsx create mode 100644 app/javascript/src/common/Mobile/AddEditModalHeader/index.tsx create mode 100644 app/javascript/src/components/Expenses/Details/Expense.tsx create mode 100644 app/javascript/src/components/Expenses/Details/Header.tsx create mode 100644 app/javascript/src/components/Expenses/Details/index.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/ExpensesSummary.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/Table/MoreOptions.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/Table/TableHeader.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/Table/TableRow.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/Table/index.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/index.tsx create mode 100644 app/javascript/src/components/Expenses/List/Header.tsx create mode 100644 app/javascript/src/components/Expenses/List/index.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/AddExpenseModal.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/DeleteExpenseModal.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/EditExpenseModal.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/ExpenseForm.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/Mobile/AddExpense.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/Mobile/EditExpense.tsx create mode 100644 app/javascript/src/components/Expenses/utils.js create mode 100644 app/javascript/src/miruIcons/svgIcons/expenseIcon.svg diff --git a/app/controllers/internal_api/v1/expenses_controller.rb b/app/controllers/internal_api/v1/expenses_controller.rb index 2469e7f907..2611fac0e2 100644 --- a/app/controllers/internal_api/v1/expenses_controller.rb +++ b/app/controllers/internal_api/v1/expenses_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class InternalApi::V1::ExpensesController < ApplicationController - before_action :set_expense, only: :show + before_action :set_expense, only: [:show, :update, :destroy] def index authorize Expense @@ -27,6 +27,22 @@ def show render :show, locals: { expense: Expense::ShowPresenter.new(@expense).process } end + def update + authorize @expense + + @expense.update!(expense_params) + + render json: { notice: I18n.t("expenses.update") }, status: :ok + end + + def destroy + authorize @expense + + @expense.destroy! + + render json: { notice: I18n.t("expenses.destroy") }, status: :ok + end + private def expense_params diff --git a/app/javascript/src/apis/expenses.ts b/app/javascript/src/apis/expenses.ts new file mode 100644 index 0000000000..322a8b97b1 --- /dev/null +++ b/app/javascript/src/apis/expenses.ts @@ -0,0 +1,27 @@ +import axios from "./api"; + +const path = "/expenses"; + +const index = async () => await axios.get(path); + +const create = async payload => await axios.post(path, payload); + +const show = async id => await axios.get(`${path}/${id}`); + +const update = async (id, payload) => axios.patch(`${path}/${id}`, payload); + +const destroy = async id => axios.delete(`${path}/${id}`); + +const createCategory = async payload => + axios.post("/expense_categories", payload); + +const expensesApi = { + index, + create, + show, + update, + destroy, + createCategory, +}; + +export default expensesApi; diff --git a/app/javascript/src/common/CustomCreatableSelect/index.tsx b/app/javascript/src/common/CustomCreatableSelect/index.tsx new file mode 100644 index 0000000000..a2c9b71d42 --- /dev/null +++ b/app/javascript/src/common/CustomCreatableSelect/index.tsx @@ -0,0 +1,138 @@ +/* eslint-disable import/exports-last */ +import React from "react"; + +import CreatableSelect from "react-select/creatable"; + +import { + customErrStyles, + customStyles, + CustomValueContainer, +} from "common/CustomReactSelectStyle"; +import { useUserContext } from "context/UserContext"; + +type CustomCreatableSelectProps = { + id?: string; + styles?: any; + components?: any; + classNamePrefix?: string; + label?: string; + isErr?: any; + isSearchable?: boolean; + isDisabled?: boolean; + ignoreDisabledFontColor?: boolean; + hideDropdownIndicator?: boolean; + handleOnClick?: (e?: any) => void; // eslint-disable-line + handleOnChange?: (e?: any) => void; // eslint-disable-line + handleonFocus?: (e?: any) => void; // eslint-disable-line + onBlur?: (e?: any) => void; // eslint-disable-line + defaultValue?: object; + onMenuClose?: (e?: any) => void; // eslint-disable-line + onMenuOpen?: (e?: any) => void; // eslint-disable-line + className?: string; + autoFocus?: boolean; + value?: object; + getOptionLabel?: (e?: any) => any; // eslint-disable-line + wrapperClassName?: string; + options?: Array; + name?: string; +}; + +export const CustomCreatableSelect = ({ + id, + isSearchable, + classNamePrefix, + options, + label, + handleOnChange, + handleonFocus, + handleOnClick, + name, + value, + isErr, + isDisabled, + styles, + components, + onMenuClose, + onMenuOpen, + ignoreDisabledFontColor, + hideDropdownIndicator, + className, + autoFocus, + onBlur, + defaultValue, + getOptionLabel, + wrapperClassName, +}: CustomCreatableSelectProps) => { + const { isDesktop } = useUserContext(); + + const getStyle = () => { + if (isErr) { + return customErrStyles(isDesktop); + } + + return customStyles( + isDesktop, + ignoreDisabledFontColor, + hideDropdownIndicator + ); + }; + + return ( +
+ null, + }} + onBlur={onBlur} + onChange={handleOnChange} + onFocus={handleonFocus} + onMenuClose={onMenuClose} + onMenuOpen={onMenuOpen} + /> +
+ ); +}; + +CustomCreatableSelect.defaultProps = { + id: "", + styles: null, + components: null, + classNamePrefix: "react-select-filter", + label: "Select", + isErr: false, + isSearchable: true, + isDisabled: false, + ignoreDisabledFontColor: false, + hideDropdownIndicator: false, + handleOnClick: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + handleOnChange: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + handleonFocus: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + onBlur: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + defaultValue: null, + onMenuClose: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + onMenuOpen: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + className: "", + autoFocus: false, + value: null, + wrapperClassName: "", +}; + +export default CustomCreatableSelect; diff --git a/app/javascript/src/common/Mobile/AddEditModalHeader/index.tsx b/app/javascript/src/common/Mobile/AddEditModalHeader/index.tsx new file mode 100644 index 0000000000..22a5131032 --- /dev/null +++ b/app/javascript/src/common/Mobile/AddEditModalHeader/index.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +import { XIcon } from "miruIcons"; + +const AddEditModalHeader = ({ title, handleOnClose }) => ( +
+ + {title} + + +
+); + +export default AddEditModalHeader; diff --git a/app/javascript/src/components/Expenses/Details/Expense.tsx b/app/javascript/src/components/Expenses/Details/Expense.tsx new file mode 100644 index 0000000000..00ce2b9681 --- /dev/null +++ b/app/javascript/src/components/Expenses/Details/Expense.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +import { currencyFormat } from "helpers"; + +const Expense = ({ expense, currency }) => ( +
+
+ + Amount + + + {currencyFormat(currency, expense?.amount)} + +
+
+
+ + Date + + + {expense?.date || "-"} + +
+
+ + Vendor + + + {expense?.vendorName || "-"} + +
+
+ + Type + + + {expense?.type || "-"} + +
+
+ + Receipt + + + {expense?.receipt || "-"} + +
+
+
+ + Description + + + {expense?.description || "-"} + +
+
+); + +export default Expense; diff --git a/app/javascript/src/components/Expenses/Details/Header.tsx b/app/javascript/src/components/Expenses/Details/Header.tsx new file mode 100644 index 0000000000..5780664780 --- /dev/null +++ b/app/javascript/src/components/Expenses/Details/Header.tsx @@ -0,0 +1,64 @@ +import React from "react"; + +import { ArrowLeftIcon, EditIcon, DeleteIcon } from "miruIcons"; +import { useNavigate } from "react-router-dom"; +import { Button } from "StyledComponents"; + +const Header = ({ expense, handleEdit, handleDelete }) => { + const navigate = useNavigate(); + + return ( +
+
+ + + {expense?.categoryName} + +
+
+ + +
+
+ + +
+
+ ); +}; + +export default Header; diff --git a/app/javascript/src/components/Expenses/Details/index.tsx b/app/javascript/src/components/Expenses/Details/index.tsx new file mode 100644 index 0000000000..694f678b24 --- /dev/null +++ b/app/javascript/src/components/Expenses/Details/index.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from "react"; + +import Logger from "js-logger"; +import { useNavigate, useParams } from "react-router-dom"; + +import expensesApi from "apis/expenses"; +import { useUserContext } from "context/UserContext"; + +import Expense from "./Expense"; +import Header from "./Header"; + +import DeleteExpenseModal from "../Modals/DeleteExpenseModal"; +import EditExpenseModal from "../Modals/EditExpenseModal"; +import EditExpense from "../Modals/Mobile/EditExpense"; +import { setCategoryData, setVendorData } from "../utils"; + +const ExpenseDetails = () => { + const [showDeleteExpenseModal, setShowDeleteExpenseModal] = + useState(false); + + const [showEditExpenseModal, setShowEditExpenseModal] = + useState(false); + const [expense, setExpense] = useState(); + const [expenseData, setExpenseData] = useState(); + + const params = useParams(); + const navigate = useNavigate(); + const { company, isDesktop } = useUserContext(); + + const fetchExpense = async () => { + try { + const resData = await expensesApi.show(params.expenseId); + const res = await expensesApi.index(); + const data = setCategoryData(res.data.categories); + res.data.categories = data; + setVendorData(res.data.vendors); + setExpenseData(res.data); + setExpense(resData.data); + } catch (e) { + Logger.error(e); + navigate("/expenses"); + } + }; + + const getExpenseData = async () => { + try { + const res = await expensesApi.index(); + const data = setCategoryData(res.data.categories); + res.data.categories = data; + setVendorData(res.data.vendors); + setExpenseData(res.data); + } catch (e) { + Logger.error(e); + setShowEditExpenseModal(false); + } + }; + + const handleEditExpense = async payload => { + await expensesApi.update(expense.id, payload); + setShowEditExpenseModal(false); + fetchExpense(); + }; + + const handleDeleteExpense = async () => { + await expensesApi.destroy(expense.id); + navigate("/expenses"); + }; + + const handleDelete = () => { + setShowDeleteExpenseModal(true); + }; + + const handleEdit = () => { + setShowEditExpenseModal(true); + }; + + useEffect(() => { + fetchExpense(); + getExpenseData(); + }, []); + + return ( +
+ {!isDesktop && showEditExpenseModal ? null : ( +
+
+ +
+ )} + {showEditExpenseModal && + (isDesktop ? ( + + ) : ( + + ))} + {showDeleteExpenseModal && ( + + )} +
+ ); +}; + +export default ExpenseDetails; diff --git a/app/javascript/src/components/Expenses/List/Container/ExpensesSummary.tsx b/app/javascript/src/components/Expenses/List/Container/ExpensesSummary.tsx new file mode 100644 index 0000000000..05719dac7b --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Container/ExpensesSummary.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +import { Categories } from "../../utils"; + +const ExpensesSummary = () => ( +
+
+ {Categories?.map(category => ( +
+
+ {category.icon} +
+
+ + {category.label} + + + {category.color} + +
+
+ ))} +
+
+); + +export default ExpensesSummary; diff --git a/app/javascript/src/components/Expenses/List/Container/Table/MoreOptions.tsx b/app/javascript/src/components/Expenses/List/Container/Table/MoreOptions.tsx new file mode 100644 index 0000000000..623fe45043 --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Container/Table/MoreOptions.tsx @@ -0,0 +1,63 @@ +import React from "react"; + +import { DeleteIcon, EditIcon, DownloadSimpleIcon } from "miruIcons"; +import { useNavigate } from "react-router-dom"; +import { Tooltip, Modal, Button } from "StyledComponents"; + +const MoreOptions = ({ + expense, + isDesktop, + showMoreOptions, + setShowMoreOptions, +}) => { + const navigate = useNavigate(); + + return isDesktop ? ( +
e.stopPropagation()} + > + + + + + + + + + +
+ ) : ( + setShowMoreOptions(false)} + > +
    +
  • + Download Expense +
  • +
+
+ ); +}; + +export default MoreOptions; diff --git a/app/javascript/src/components/Expenses/List/Container/Table/TableHeader.tsx b/app/javascript/src/components/Expenses/List/Container/Table/TableHeader.tsx new file mode 100644 index 0000000000..cc39cfa106 --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Container/Table/TableHeader.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +const TableHeader = () => ( + + + + 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/Invoices/List/MoreOptions.tsx b/app/javascript/src/components/Invoices/List/MoreOptions.tsx index ac80ae7383..fdcda8e81a 100644 --- a/app/javascript/src/components/Invoices/List/MoreOptions.tsx +++ b/app/javascript/src/components/Invoices/List/MoreOptions.tsx @@ -37,7 +37,7 @@ const MoreOptions = ({ return isDesktop ? ( <>
e.stopPropagation()} > diff --git a/app/javascript/src/components/Navbar/utils.tsx b/app/javascript/src/components/Navbar/utils.tsx index dbde5a78c3..60f6c2f151 100644 --- a/app/javascript/src/components/Navbar/utils.tsx +++ b/app/javascript/src/components/Navbar/utils.tsx @@ -10,6 +10,7 @@ import { PaymentsIcon, SettingIcon, CalendarIcon, + ExpenseIconSVG, } from "miruIcons"; import { NavLink } from "react-router-dom"; @@ -65,6 +66,12 @@ const navOptions = [ path: Paths.Leave_Management, allowedRoles: ["admin", "owner", "employee"], }, + { + logo: , + label: "Expenses", + path: Paths.EXPENSES, + allowedRoles: ["admin", "owner", "book_keeper"], + }, ]; const navAdminMobileOptions = [ @@ -103,6 +110,12 @@ const navAdminMobileOptions = [ label: "Payments", path: Paths.PAYMENTS, }, + { + logo: , + label: "Expenses", + dataCy: "expenses-tab", + path: Paths.EXPENSES, + }, ]; const navClientOptions = [ @@ -118,6 +131,12 @@ const navClientOptions = [ path: "/settings/profile", allowedRoles: ["admin", "owner", "book_keeper", "client"], }, + { + logo: , + label: "Expenses", + dataCy: "expenses-tab", + path: Paths.EXPENSES, + }, ]; const activeClassName = diff --git a/app/javascript/src/components/Profile/Organization/Holidays/HolidaysModal.tsx b/app/javascript/src/components/Profile/Organization/Holidays/HolidaysModal.tsx index d2940fd7ef..2a027d1f8f 100644 --- a/app/javascript/src/components/Profile/Organization/Holidays/HolidaysModal.tsx +++ b/app/javascript/src/components/Profile/Organization/Holidays/HolidaysModal.tsx @@ -53,7 +53,7 @@ const HolidayModal = ({ {yearCalendar[quarters].quarter.map( ( month, - key //eslint-disable-line + key //eslint-disable-line ) => ( + + + + + + + + + + + + + + + + + diff --git a/app/models/expense_category.rb b/app/models/expense_category.rb index c36e3e9eb1..2538ff4f49 100644 --- a/app/models/expense_category.rb +++ b/app/models/expense_category.rb @@ -22,7 +22,7 @@ class ExpenseCategory < ApplicationRecord DEFAULT_CATEGORIES = [ { name: "Salary", default: true }, - { name: "Repair & Maintenance", default: true }, + { name: "Repairs & Maintenance", default: true }, { name: "Rent", default: true }, { name: "Food", default: true }, { name: "Travel", default: true }, diff --git a/app/policies/expense_policy.rb b/app/policies/expense_policy.rb index 3bb43d4635..61636b5ecc 100644 --- a/app/policies/expense_policy.rb +++ b/app/policies/expense_policy.rb @@ -15,6 +15,14 @@ def show? authorize_current_user end + def update? + authorize_current_user + end + + def destroy? + authorize_current_user + end + def authorize_current_user unless user.current_workspace_id == record.company_id @error_message_key = :different_workspace diff --git a/config/locales/en.yml b/config/locales/en.yml index 043fb06b16..10ad81e95b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -292,3 +292,6 @@ en: sessions: failure: invalid: Invalid email or password + expenses: + update: "Expense updated successfully" + destroy: "Expense deleted successfully" \ No newline at end of file diff --git a/config/routes/internal_api.rb b/config/routes/internal_api.rb index c0027dbe4e..3544e69123 100644 --- a/config/routes/internal_api.rb +++ b/config/routes/internal_api.rb @@ -141,7 +141,7 @@ resources :vendors, only: [:create] resources :expense_categories, only: [:create] - resources :expenses, only: [:create, :index, :show] + resources :expenses, only: [:create, :index, :show, :update, :destroy] resources :bulk_previous_employments, only: [:update] resources :leaves, as: "leave" do