diff --git a/app/controllers/internal_api/v1/expenses_controller.rb b/app/controllers/internal_api/v1/expenses_controller.rb index 2611fac0e2..3bde94a883 100644 --- a/app/controllers/internal_api/v1/expenses_controller.rb +++ b/app/controllers/internal_api/v1/expenses_controller.rb @@ -47,7 +47,7 @@ def destroy def expense_params params.require(:expense).permit( - :amount, :date, :description, :expense_type, :expense_category_id, :vendor_id, :receipts + :amount, :date, :description, :expense_type, :expense_category_id, :vendor_id, receipts: [] ) end diff --git a/app/javascript/src/StyledComponents/Pagination/index.tsx b/app/javascript/src/StyledComponents/Pagination/index.tsx index 948d73e996..08fd9c9abe 100644 --- a/app/javascript/src/StyledComponents/Pagination/index.tsx +++ b/app/javascript/src/StyledComponents/Pagination/index.tsx @@ -105,7 +105,6 @@ const Pagination = ({
setSearchQuery(e.target.value)} - /> - -
- - - {/* Todo: Uncomment when filter functionality is added +
handleClick(item)} + > + + {item.label} + + + {item.date} + + + {currencyFormat(company.base_currency, item.amount)} + +
+ ); +}; + +const Header = ({ + setShowAddExpenseModal, + fetchSearchResults, + clearSearch, +}) => ( +
+

+ Expenses +

+ + {/* 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 index 781077983f..916360292b 100644 --- a/app/javascript/src/components/Expenses/List/index.tsx +++ b/app/javascript/src/components/Expenses/List/index.tsx @@ -1,4 +1,6 @@ -import React, { Fragment, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; + +import { Toastr } from "StyledComponents"; import expensesApi from "apis/expenses"; import Loader from "common/Loader/index"; @@ -10,7 +12,11 @@ import Header from "./Header"; import AddExpenseModal from "../Modals/AddExpenseModal"; import AddExpense from "../Modals/Mobile/AddExpense"; -import { setCategoryData, setVendorData } from "../utils"; +import { + setCategoryData, + setVendorData, + unmapExpenseListForDropdown, +} from "../utils"; const Expenses = () => { const { isDesktop } = useUserContext(); @@ -28,33 +34,74 @@ const Expenses = () => { setIsLoading(false); }; + const fetchSearchResults = async ( + searchString, + updateExpenseData = false + ) => { + try { + const res = await expensesApi.index(`query=${searchString}`); + if (updateExpenseData) { + setExpenseData(res.data); + } else { + const dropdownList = unmapExpenseListForDropdown(res); + + return dropdownList; + } + } catch { + Toastr.error("Couldn't complete search"); + } + }; + const handleAddExpense = async payload => { - await expensesApi.create(payload); + const res = await expensesApi.create(payload); setShowAddExpenseModal(false); - fetchExpenses(); + setIsLoading(true); + if (res.status == 200) { + fetchExpenses(); + } }; useEffect(() => { fetchExpenses(); }, []); - const ExpensesLayout = () => ( -
- {isLoading ? ( + if (isLoading) { + return ( +
- ) : ( - -
- - {showAddExpenseModal && ( - - )} - +
+ ); + } + + const ExpensesLayout = () => ( +
+
+ + {/* TODO: Fix pagination backend (missing attributes: items,page) and uncomment + */} + {showAddExpenseModal && ( + )}
); diff --git a/app/javascript/src/components/Expenses/Modals/DeleteExpenseModal.tsx b/app/javascript/src/components/Expenses/Modals/DeleteExpenseModal.tsx index f277f649af..c3c929ce2f 100644 --- a/app/javascript/src/components/Expenses/Modals/DeleteExpenseModal.tsx +++ b/app/javascript/src/components/Expenses/Modals/DeleteExpenseModal.tsx @@ -1,45 +1,62 @@ import React from "react"; +import { useNavigate } from "react-router-dom"; import { Modal, Button } from "StyledComponents"; +import expensesApi from "apis/expenses"; + const DeleteExpenseModal = ({ setShowDeleteExpenseModal, showDeleteExpenseModal, - handleDeleteExpense, -}) => ( - setShowDeleteExpenseModal(false)} - > -
-
Delete Expense
-

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

-
-
- - -
-
-); + expense, + fetchExpenses, +}) => { + const navigate = useNavigate(); + + const handleDeleteExpense = async () => { + const res = await expensesApi.destroy(expense.id); + if (res.status === 200) { + navigate("/expenses/", { replace: true }); + fetchExpenses(); + } + setShowDeleteExpenseModal(false); + }; + + return ( + 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/ExpenseForm.tsx b/app/javascript/src/components/Expenses/Modals/ExpenseForm.tsx index d5f8996bfa..16062d5a13 100644 --- a/app/javascript/src/components/Expenses/Modals/ExpenseForm.tsx +++ b/app/javascript/src/components/Expenses/Modals/ExpenseForm.tsx @@ -1,8 +1,8 @@ -import React, { useRef, useState, ChangeEvent, useEffect } from "react"; +import React, { useRef, useState, useEffect, memo } from "react"; import dayjs from "dayjs"; import { useOutsideClick } from "helpers"; -import { CalendarIcon, FileIcon, FilePdfIcon, XIcon } from "miruIcons"; +import { CalendarIcon, FileIcon, XIcon } from "miruIcons"; import { components } from "react-select"; import { Button } from "StyledComponents"; @@ -11,7 +11,6 @@ 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"; @@ -29,34 +28,45 @@ const ExpenseForm = ({ dayjs(expense?.date) || dayjs() ); const [vendor, setVendor] = useState(""); - const [amount, setAmount] = useState(expense?.amount || ""); + const [amount, setAmount] = useState(expense?.amount || ""); const [category, setCategory] = useState(""); const [newCategory, setNewCategory] = useState(""); + const [newVendor, setNewVendor] = useState(""); const [description, setDescription] = useState( expense?.description || "" ); const [expenseType, setExpenseType] = useState( - expense?.type || "personal" + expense?.type || "business" ); - const [receipt, setReceipt] = useState(expense?.receipt || ""); - + const [receipts, setReceipts] = useState(expense?.receipts || ""); const isFormActionDisabled = !( expenseDate && - vendor && + (vendor || newVendor) && amount && (category || newCategory) ); - const { Option } = components; + const { Option, SingleValue } = components; + const IconOption = props => ( - ); + const MemoizedIconOption = memo(IconOption); + + const CustomSingleValue = props => ( + +
+ {props?.data?.icon} + {props?.data?.label} +
+
+ ); const setExpenseData = () => { if (expense) { @@ -78,12 +88,6 @@ const ExpenseForm = ({ }; const handleCategory = async category => { - category.label = ( -
- {category.icon} - {category.label} -
- ); if (expenseData.categories.includes(category)) { setCategory(category); } else { @@ -105,60 +109,107 @@ const ExpenseForm = ({ newCategoryValue.label = newCategoryValue.name; delete newCategoryValue.name; + setCategory(null); setNewCategory(newCategoryValue); } } }; + const handleVendor = async vendor => { + if (expenseData.vendors.includes(vendor)) { + setVendor(vendor); + } else { + const payload = { + vendor: { + name: vendor.value, + }, + }; + const res = await expensesApi.createVendors(payload); + const expenses = await expensesApi.index(); + + if (res.status == 200 && expenses.status == 200) { + const newVendorValue = expenses.data.vendors.find( + val => val.name == vendor.value + ); + + newVendorValue.value = newVendorValue.name; + newVendorValue.label = newVendorValue.name; + delete newVendorValue.name; + + setVendor(null); + setNewVendor(newVendorValue); + } + } + }; + const handleFileUpload = () => { if (fileRef.current) { fileRef.current.click(); } }; - const handleFileSelection = (event: ChangeEvent) => { - const selectedFile = event.target.files?.[0]; - if (selectedFile) { - setReceipt(selectedFile); - } + const handleFileSelection = event => { + const uploadedFiles = [...event.target.files]; + // We are restricting uploads to a max of 10 files, each with a size limit of 2 mb. + const sortedFiles = uploadedFiles?.filter( + (file, index) => file.size < 2097152 && index < 10 + ); + setReceipts(sortedFiles); }; 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 formData = new FormData(); + + formData.append("expense[amount]", amount); + formData.append("expense[date]", expenseDate); + formData.append("expense[description]", description); + formData.append("expense[expense_type]", expenseType); + formData.append( + "expense[expense_category_id]", + category?.id || newCategory?.id + ); + formData.append("expense[vendor_id]", vendor?.id || newVendor?.id); + if (receipts) { + receipts?.forEach(file => { + formData.append(`expense[receipts][]`, file); + }); + } + + handleFormAction(formData); + }; + + const removeReceipt = receipt => { + const updatedReceipts = receipts.filter(item => item !== receipt); + setReceipts(updatedReceipts); }; const ReceiptCard = () => ( -
-
- -
-
- {receipt.name} -
- PDF -
- {Math.ceil(receipt.size / 1024)}kb +
+ {receipts.map(receipt => ( +
+
+ +
+
+ {receipt.name} +
+ PDF +
+ {Math.ceil(receipt.size / 1024)}kb +
+
+
-
- + ))}
); @@ -172,7 +223,8 @@ const ExpenseForm = ({ Upload file
- setVendor(vendor)} +
@@ -244,13 +296,16 @@ const ExpenseForm = ({
@@ -266,35 +321,35 @@ const ExpenseForm = ({
- Expense Type (optional) + Expense Type
{ - setExpenseType("personal"); + setExpenseType("business"); }} /> { - setExpenseType("business"); + setExpenseType("personal"); }} />
@@ -303,7 +358,7 @@ const ExpenseForm = ({ Receipt (optional) - {receipt ? : } + {receipts.length > 0 ? : }
diff --git a/app/javascript/src/components/Expenses/utils.js b/app/javascript/src/components/Expenses/utils.js index 9cc4b97bd6..519cb76a52 100644 --- a/app/javascript/src/components/Expenses/utils.js +++ b/app/javascript/src/components/Expenses/utils.js @@ -1,5 +1,6 @@ import React from "react"; +import JSZip from "jszip"; import { ExpenseIconSVG, PaymentsIcon, @@ -11,6 +12,7 @@ import { CarIcon, HouseIcon, } from "miruIcons"; +import { Toastr } from "StyledComponents"; export const Categories = [ { @@ -112,3 +114,93 @@ export const setCategoryData = rawCategories => { return newCategories; }; + +export const unmapExpenseListForDropdown = input => { + const ExpenseList = input.data.expenses; + + return ExpenseList.map(item => ({ + label: item.categoryName, + value: item.id, + date: item.date, + amount: item.amount, + })); +}; + +export const FileDownloader = ({ fileUrl }) => { + // Extracting filename from URL + const fileName = fileUrl.substring(fileUrl.lastIndexOf("/") + 1); + const handleDownload = async () => { + try { + const response = await fetch(fileUrl); + const blob = await response.blob(); + + // Creating a URL for the blob + const url = window.URL.createObjectURL(blob); + + // Creating a link element + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", fileName); + document.body.appendChild(link); + link.click(); + + //cleanup + link.parentNode.removeChild(link); + window.URL.revokeObjectURL(url); + } catch { + Toastr.error("Error downloading file"); + } + }; + + return {fileName}; +}; + +export const DownloadAll = async fileUrls => { + try { + const fetchBlobs = async () => { + const blobs = []; + + //Creating array of URLs for blob + for (const fileUrl of fileUrls) { + const response = await fetch(fileUrl); + const blob = await response.blob(); + const fileName = fileUrl.substring(fileUrl.lastIndexOf("/") + 1); + blobs.push({ fileName, blob }); + } + + return blobs; + }; + + //Array of blob URLs + const fetchedFiles = await fetchBlobs(); + + //Creating zip + const createZipBlob = async files => { + const zip = new JSZip(); + + files.forEach(({ fileName, blob }) => { + zip.file(fileName, blob); + }); + + return await zip.generateAsync({ type: "blob" }); + }; + + //Creating zip URL + const zipBlob = await createZipBlob(fetchedFiles); + const zipUrl = window.URL.createObjectURL(zipBlob); + + // Creating a link element and downloading zip file + const link = document.createElement("a"); + link.href = zipUrl; + link.setAttribute("download", "Receipt(s).zip"); + document.body.appendChild(link); + + link.click(); + + //cleanup + link.parentNode.removeChild(link); + window.URL.revokeObjectURL(zipUrl); + } catch { + Toastr.error("Error downloading file"); + } +}; diff --git a/app/javascript/src/components/Navbar/utils.tsx b/app/javascript/src/components/Navbar/utils.tsx index 6fa9bb862f..570d045c6d 100644 --- a/app/javascript/src/components/Navbar/utils.tsx +++ b/app/javascript/src/components/Navbar/utils.tsx @@ -10,7 +10,7 @@ import { PaymentsIcon, SettingIcon, CalendarIcon, - // ExpenseIconSVG + CoinsIcon, } from "miruIcons"; import { NavLink } from "react-router-dom"; @@ -66,12 +66,12 @@ const navOptions = [ path: Paths.Leave_Management, allowedRoles: ["admin", "owner", "employee"], }, - // { - // logo: , - // label: "Expenses", - // path: Paths.EXPENSES, - // allowedRoles: ["admin", "owner", "book_keeper"], - // }, + { + logo: , + label: "Expenses", + path: Paths.EXPENSES, + allowedRoles: ["admin", "owner", "book_keeper"], + }, ]; const navAdminMobileOptions = [ @@ -110,12 +110,12 @@ const navAdminMobileOptions = [ label: "Payments", path: Paths.PAYMENTS, }, - // { - // logo: , - // label: "Expenses", - // dataCy: "expenses-tab", - // path: Paths.EXPENSES, - // }, + { + logo: , + label: "Expenses", + dataCy: "expenses-tab", + path: Paths.EXPENSES, + }, ]; const navClientOptions = [ @@ -131,12 +131,12 @@ const navClientOptions = [ path: "/settings/profile", allowedRoles: ["admin", "owner", "book_keeper", "client"], }, - // { - // logo: , - // label: "Expenses", - // dataCy: "expenses-tab", - // path: Paths.EXPENSES, - // }, + { + logo: , + label: "Expenses", + dataCy: "expenses-tab", + path: Paths.EXPENSES, + }, ]; const activeClassName = diff --git a/app/javascript/stylesheets/application.scss b/app/javascript/stylesheets/application.scss index b1259f022e..02fcbee067 100644 --- a/app/javascript/stylesheets/application.scss +++ b/app/javascript/stylesheets/application.scss @@ -557,7 +557,7 @@ body { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; - -webkit-line-clamp: 2; + -webkit-line-clamp: 1; -webkit-box-orient: vertical; } diff --git a/app/views/internal_api/v1/expenses/index.json.jbuilder b/app/views/internal_api/v1/expenses/index.json.jbuilder index f8f1724895..e7c6bf2c9e 100644 --- a/app/views/internal_api/v1/expenses/index.json.jbuilder +++ b/app/views/internal_api/v1/expenses/index.json.jbuilder @@ -8,6 +8,7 @@ json.expenses expenses do |expense| json.category_name expense.expense_category.name json.vendor_name expense.vendor&.name json.date expense.formatted_date + json.receipts expense.attached_receipts_urls end json.vendors vendors do | vendor | diff --git a/package.json b/package.json index 03546e64ed..898e020c73 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "jquery": "^3.6.0", "js-cookie": "^3.0.5", "js-logger": "^1.6.1", + "jszip": "^3.10.1", "mini-css-extract-plugin": "^2.7.2", "pnp-webpack-plugin": "^1.7.0", "postcss": "^8.4.21", diff --git a/spec/requests/internal_api/v1/expenses/index_spec.rb b/spec/requests/internal_api/v1/expenses/index_spec.rb index 60b390d2a5..f6ee453f4f 100644 --- a/spec/requests/internal_api/v1/expenses/index_spec.rb +++ b/spec/requests/internal_api/v1/expenses/index_spec.rb @@ -54,7 +54,8 @@ "expenseType" => expense.expense_type, "categoryName" => expense.expense_category.name, "vendorName" => expense.vendor&.name, - "description" => expense.description + "description" => expense.description, + "receipts" => [] } end expect(json_response["expenses"]).to eq(expected_data) diff --git a/yarn.lock b/yarn.lock index d3782bc1b3..359d8f17d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4386,6 +4386,11 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immutable@^4.0.0: version "4.3.0" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.0.tgz#eb1738f14ffb39fd068b1dbe1296117484dd34be" @@ -4805,6 +4810,16 @@ jsonfile@^6.0.1: array-includes "^3.1.5" object.assign "^4.1.3" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -4836,6 +4851,13 @@ libphonenumber-js@^1.10.20: resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.26.tgz#3e6604357b3434b0005f85778b44153f4fadeecd" integrity sha512-oB3l4J5gEhMV+ymmlIjWedsbCpsNRqbEZ/E/MpN2QVyinKNra6DcuXywxSk/72M3DZDoH/6kzurOq1erznBMwQ== +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.5.tgz#19e57fd06ccc3848fd1891655b5a447092225b25" @@ -5396,6 +5418,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -6459,7 +6486,7 @@ read-cache@^1.0.0: dependencies: pify "^2.3.0" -readable-stream@^2.0.1: +readable-stream@^2.0.1, readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -6799,6 +6826,11 @@ serve-static@1.15.0: parseurl "~1.3.3" send "0.18.0" +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"