,
+ text: ,
href: '/expenses/new',
},
],
@@ -140,12 +179,12 @@ export default [
},
{
text: 'Receivable Aging Summary',
- href: '/financial-reports/receivable-aging-summary'
+ href: '/financial-reports/receivable-aging-summary',
},
{
text: 'Payable Aging Summary',
- href: '/financial-reports/payable-aging-summary'
- }
+ href: '/financial-reports/payable-aging-summary',
+ },
],
},
{
diff --git a/client/src/containers/Purchases/Bill/BillActionsBar.js b/client/src/containers/Purchases/Bill/BillActionsBar.js
new file mode 100644
index 000000000..dad1c4f2b
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/BillActionsBar.js
@@ -0,0 +1,148 @@
+import React, { useCallback, useState, useMemo } from 'react';
+import Icon from 'components/Icon';
+import {
+ Button,
+ Classes,
+ Menu,
+ MenuItem,
+ Popover,
+ NavbarDivider,
+ NavbarGroup,
+ PopoverInteractionKind,
+ Position,
+ Intent,
+} from '@blueprintjs/core';
+
+import classNames from 'classnames';
+import { useRouteMatch, useHistory } from 'react-router-dom';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+
+import { connect } from 'react-redux';
+import FilterDropdown from 'components/FilterDropdown';
+import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar';
+
+import { If, DashboardActionViewsList } from 'components';
+
+import withResourceDetail from 'containers/Resources/withResourceDetails';
+import withBillActions from './withBillActions';
+import withBills from './withBills';
+
+import { compose } from 'utils';
+
+function BillActionsBar({
+ // #withResourceDetail
+ resourceFields,
+
+ //#withBills
+ billsViews,
+
+ //#withBillActions
+ addBillsTableQueries,
+
+ // #own Porps
+ onFilterChanged,
+ selectedRows = [],
+}) {
+ const history = useHistory();
+ const { path } = useRouteMatch();
+ const [filterCount, setFilterCount] = useState(0);
+ const { formatMessage } = useIntl();
+
+ const handleClickNewBill = useCallback(() => {
+ history.push('/bills/new');
+ }, [history]);
+
+ // const FilterDropdown = FilterDropdown({
+ // initialCondition: {
+ // fieldKey: '',
+ // compatator: '',
+ // value: '',
+ // },
+ // fields: resourceFields,
+ // onFilterChange: (filterConditions) => {
+ // addBillsTableQueries({
+ // filter_roles: filterConditions || '',
+ // });
+ // onFilterChanged && onFilterChanged(filterConditions);
+ // },
+ // });
+
+ const hasSelectedRows = useMemo(() => selectedRows.length > 0, [
+ selectedRows,
+ ]);
+
+ return (
+
+
+
+
+ }
+ text={}
+ onClick={handleClickNewBill}
+ />
+
+
+ ) : (
+ `${filterCount} ${formatMessage({ id: 'filters_applied' })}`
+ )
+ }
+ icon={}
+ />
+
+
+ }
+ text={}
+ intent={Intent.DANGER}
+ // onClick={handleBulkDelete}
+ />
+
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+
+
+ );
+}
+
+const mapStateToProps = (state, props) => ({
+ resourceName: 'bills',
+});
+const withBillActionsBar = connect(mapStateToProps);
+
+export default compose(
+ withBillActionsBar,
+ withResourceDetail(({ resourceFields }) => ({
+ resourceFields,
+ })),
+
+ // withBills(({billsViews})=>({
+ // billsViews
+ // })),
+
+ withBillActions,
+)(BillActionsBar);
diff --git a/client/src/containers/Purchases/Bill/BillForm.js b/client/src/containers/Purchases/Bill/BillForm.js
new file mode 100644
index 000000000..a05f79137
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/BillForm.js
@@ -0,0 +1,319 @@
+import React, {
+ useMemo,
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+} from 'react';
+import * as Yup from 'yup';
+import { useFormik } from 'formik';
+import moment from 'moment';
+import { Intent, FormGroup, TextArea } from '@blueprintjs/core';
+
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import { pick } from 'lodash';
+
+import BillFormHeader from './BillFormHeader';
+import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable';
+import BillFormFooter from './BillFormFooter';
+
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withMediaActions from 'containers/Media/withMediaActions';
+import withBillActions from './withBillActions';
+import withBillDetail from './withBillDetail';
+
+import { AppToaster } from 'components';
+import Dragzone from 'components/Dragzone';
+import useMedia from 'hooks/useMedia';
+
+import { compose, repeatValue } from 'utils';
+
+const MIN_LINES_NUMBER = 4;
+
+function BillForm({
+ //#WithMedia
+ requestSubmitMedia,
+ requestDeleteMedia,
+
+ //#withBillActions
+ requestSubmitBill,
+ requestEditBill,
+
+ //#withDashboard
+ changePageTitle,
+ changePageSubtitle,
+
+ //#withBillDetail
+ bill,
+
+ //#Own Props
+ billId,
+ onFormSubmit,
+ onCancelForm,
+}) {
+ const { formatMessage } = useIntl();
+ const [payload, setPayload] = useState({});
+
+ const {
+ setFiles,
+ saveMedia,
+ deletedFiles,
+ setDeletedFiles,
+ deleteMedia,
+ } = useMedia({
+ saveCallback: requestSubmitMedia,
+ deleteCallback: requestDeleteMedia,
+ });
+
+ const handleDropFiles = useCallback((_files) => {
+ setFiles(_files.filter((file) => file.uploaded === false));
+ }, []);
+
+ const savedMediaIds = useRef([]);
+ const clearSavedMediaIds = () => {
+ savedMediaIds.current = [];
+ };
+
+ useEffect(() => {
+ if (bill && bill.id) {
+ changePageTitle(formatMessage({ id: 'edit_bill' }));
+ } else {
+ changePageTitle(formatMessage({ id: 'new_bill' }));
+ }
+ }, [changePageTitle, bill, formatMessage]);
+
+ // @todo abstruct validation schema to sperated file.
+ const validationSchema = Yup.object().shape({
+ vendor_id: Yup.number()
+ .required()
+ .label(formatMessage({ id: 'vendor_name_' })),
+ bill_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'bill_date_' })),
+ due_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'due_date_' })),
+ bill_number: Yup.number()
+ .required()
+ .label(formatMessage({ id: 'bill_number_' })),
+ reference_no: Yup.string().min(1).max(255),
+ status: Yup.string().required().nullable(),
+ note: Yup.string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .label(formatMessage({ id: 'note' })),
+ entries: Yup.array().of(
+ Yup.object().shape({
+ quantity: Yup.number().nullable(),
+ rate: Yup.number().nullable(),
+ item_id: Yup.number()
+ .nullable()
+ .when(['quantity', 'rate'], {
+ is: (quantity, rate) => quantity || rate,
+ then: Yup.number().required(),
+ }),
+ total: Yup.number().nullable(),
+ discount: Yup.number().nullable(),
+ description: Yup.string().nullable(),
+ }),
+ ),
+ });
+
+ const saveBillSubmit = useCallback(
+ (payload) => {
+ onFormSubmit && onFormSubmit(payload);
+ },
+ [onFormSubmit],
+ );
+
+ const defaultBill = useMemo(() => ({
+ index: 0,
+ item_id: null,
+ rate: null,
+ discount: 0,
+ quantity: null,
+ description: '',
+ }));
+
+ const defaultInitialValues = useMemo(
+ () => ({
+ vendor_id: '',
+ bill_number: '',
+ bill_date: moment(new Date()).format('YYYY-MM-DD'),
+ due_date: moment(new Date()).format('YYYY-MM-DD'),
+ status: 'Bill',
+ reference_no: '',
+ note: '',
+ entries: [...repeatValue(defaultBill, MIN_LINES_NUMBER)],
+ }),
+ [defaultBill],
+ );
+
+ const orderingIndex = (_bill) => {
+ return _bill.map((item, index) => ({
+ ...item,
+ index: index + 1,
+ }));
+ };
+
+ const initialValues = useMemo(
+ () => ({
+ ...(bill
+ ? {
+ ...pick(bill, Object.keys(defaultInitialValues)),
+ entries: [
+ ...bill.entries.map((bill) => ({
+ ...pick(bill, Object.keys(defaultBill)),
+ })),
+ ...repeatValue(
+ defaultBill,
+ Math.max(MIN_LINES_NUMBER - bill.entries.length, 0),
+ ),
+ ],
+ }
+ : {
+ ...defaultInitialValues,
+ entries: orderingIndex(defaultInitialValues.entries),
+ }),
+ }),
+ [bill, defaultInitialValues, defaultBill],
+ );
+
+ const initialAttachmentFiles = useMemo(() => {
+ return bill && bill.media
+ ? bill.media.map((attach) => ({
+ preview: attach.attachment_file,
+ uploaded: true,
+ metadata: { ...attach },
+ }))
+ : [];
+ }, [bill]);
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ validationSchema,
+ initialValues: {
+ ...initialValues,
+ },
+ onSubmit: async (values, { setSubmitting, setErrors, resetForm }) => {
+ setSubmitting(true);
+
+ const form = {
+ ...values,
+ entries: values.entries.filter(
+ (item) => item.item_id && item.quantity,
+ ),
+ };
+ if (bill && bill.id) {
+ requestEditBill(bill.id, form)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_bill_has_been_successfully_edited',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ saveBillSubmit({ action: 'update', ...payload });
+ resetForm();
+ })
+ .catch((error) => {
+ setSubmitting(false);
+ });
+ } else {
+ requestSubmitBill(form)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage(
+ { id: 'the_bill_has_been_successfully_created' },
+ { number: values.bill_number },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ saveBillSubmit({ action: 'new', ...payload });
+ resetForm();
+ })
+ .catch((errors) => {
+ setSubmitting(false);
+ });
+ }
+ },
+ });
+
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPayload(payload);
+ formik.submitForm();
+ },
+ [setPayload, formik],
+ );
+
+ const handleCancelClick = useCallback(
+ (payload) => {
+ onCancelForm && onCancelForm(payload);
+ },
+ [onCancelForm],
+ );
+
+ const handleDeleteFile = useCallback(
+ (_deletedFiles) => {
+ _deletedFiles.forEach((deletedFile) => {
+ if (deletedFile.uploaded && deletedFile.metadata.id) {
+ setDeletedFiles([...deletedFiles, deletedFile.metadata.id]);
+ }
+ });
+ },
+ [setDeletedFiles, deletedFiles],
+ );
+
+ const onClickCleanAllLines = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingIndex([...repeatValue(defaultBill, MIN_LINES_NUMBER)]),
+ );
+ };
+
+ const onClickAddNewRow = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingIndex([...formik.values.entries, defaultBill]),
+ );
+ };
+ return (
+
+
+
+
+ );
+}
+
+export default compose(
+ withBillActions,
+ withDashboardActions,
+ withMediaActions,
+ withBillDetail(),
+)(BillForm);
diff --git a/client/src/containers/Purchases/Bill/BillFormFooter.js b/client/src/containers/Purchases/Bill/BillFormFooter.js
new file mode 100644
index 000000000..e08a5eaec
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/BillFormFooter.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import { Intent, Button } from '@blueprintjs/core';
+import { FormattedMessage as T } from 'react-intl';
+
+export default function BillFormFooter({
+ formik: { isSubmitting },
+ onSubmitClick,
+ onCancelClick,
+ bill,
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/Purchases/Bill/BillFormHeader.js b/client/src/containers/Purchases/Bill/BillFormHeader.js
new file mode 100644
index 000000000..ec609ddf2
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/BillFormHeader.js
@@ -0,0 +1,190 @@
+import React, { useMemo, useCallback, useState } from 'react';
+import {
+ FormGroup,
+ InputGroup,
+ Intent,
+ Position,
+ MenuItem,
+ Classes,
+} from '@blueprintjs/core';
+import { DateInput } from '@blueprintjs/datetime';
+import { FormattedMessage as T } from 'react-intl';
+import { Row, Col } from 'react-grid-system';
+import moment from 'moment';
+import { momentFormatter, compose, tansformDateValue } from 'utils';
+import classNames from 'classnames';
+import {
+ AccountsSelectList,
+ ListSelect,
+ ErrorMessage,
+ FieldRequiredHint,
+ Hint,
+} from 'components';
+
+// import withCustomers from 'containers/Customers/withCustomers';
+import withVendors from 'containers/Vendors/withVendors';
+import withAccounts from 'containers/Accounts/withAccounts';
+
+function BillFormHeader({
+ formik: { errors, touched, setFieldValue, getFieldProps, values },
+
+ //#withVendors
+ vendorsCurrentPage,
+ vendorItems,
+ //#withAccouts
+ accountsList,
+}) {
+ const handleDateChange = useCallback(
+ (date_filed) => (date) => {
+ const formatted = moment(date).format('YYYY-MM-DD');
+ setFieldValue(date_filed, formatted);
+ },
+ [setFieldValue],
+ );
+
+ const onChangeSelected = useCallback(
+ (filedName) => {
+ return (item) => {
+ setFieldValue(filedName, item.id);
+ };
+ },
+ [setFieldValue],
+ );
+
+ const vendorNameRenderer = useCallback(
+ (accept, { handleClick }) => (
+
+ ),
+ [],
+ );
+
+ // Filter vendor name
+ const filterVendorAccount = (query, vendor, _index, exactMatch) => {
+ const normalizedTitle = vendor.display_name.toLowerCase();
+ const normalizedQuery = query.toLowerCase();
+ if (exactMatch) {
+ return normalizedTitle === normalizedQuery;
+ } else {
+ return (
+ `${vendor.display_name} ${normalizedTitle}`.indexOf(normalizedQuery) >=
+ 0
+ );
+ }
+ };
+
+ console.log(vendorsCurrentPage, 'vendorsCurrentPage');
+ console.log(vendorItems, 'vendorItems');
+ return (
+
+
+ {/* vendor account name */}
+ }
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ labelInfo={}
+ intent={errors.vendor_id && touched.vendor_id && Intent.DANGER}
+ helperText={
+
+ }
+ >
+ }
+ itemRenderer={vendorNameRenderer}
+ itemPredicate={filterVendorAccount}
+ popoverProps={{ minimal: true }}
+ onItemSelect={onChangeSelected('vendor_id')}
+ selectedItem={values.vendor_id}
+ selectedItemProp={'id'}
+ defaultText={}
+ labelProp={'display_name'}
+ />
+
+
+
+ }
+ inline={true}
+ labelInfo={}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={errors.bill_date && touched.bill_date && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+
+ }
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={errors.due_date && touched.due_date && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+
+ {/* bill number */}
+ }
+ inline={true}
+ className={('form-group--estimate', Classes.FILL)}
+ labelInfo={}
+ intent={errors.bill_number && touched.bill_number && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+
}
+ inline={true}
+ className={classNames('form-group--reference', Classes.FILL)}
+ intent={errors.reference_no && touched.reference_no && Intent.DANGER}
+ helperText={
}
+ >
+
+
+
+ );
+}
+
+export default compose(
+ withVendors(({ vendorsCurrentPage, vendorItems }) => ({
+ vendorsCurrentPage,
+ vendorItems,
+ })),
+ withAccounts(({ accountsList }) => ({
+ accountsList,
+ })),
+)(BillFormHeader);
diff --git a/client/src/containers/Purchases/Bill/BillList.js b/client/src/containers/Purchases/Bill/BillList.js
new file mode 100644
index 000000000..158c9a61b
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/BillList.js
@@ -0,0 +1,178 @@
+import React, { useEffect, useCallback, useMemo, useState } from 'react';
+import { Route, Switch, useHistory } from 'react-router-dom';
+import { useQuery, queryCache } from 'react-query';
+import { Alert, Intent } from '@blueprintjs/core';
+
+import AppToaster from 'components/AppToaster';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import DashboardPageContent from 'components/Dashboard/DashboardPageContent';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import BillsDataTable from './BillsDataTable';
+import BillActionsBar from './BillActionsBar';
+import BillViewTabs from './BillViewTabs';
+
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withResourceActions from 'containers/Resources/withResourcesActions';
+
+import withBills from './withBills';
+import withBillActions from './withBillActions';
+import withViewsActions from 'containers/Views/withViewsActions';
+
+import { compose } from 'utils';
+
+function BillList({
+ // #withDashboardActions
+ changePageTitle,
+
+ // #withViewsActions
+ requestFetchResourceViews,
+ requestFetchResourceFields,
+
+ //#withBills
+ billsTableQuery,
+
+ //#withBillActions
+ requestFetchBillsTable,
+ requestDeleteBill,
+ addBillsTableQueries,
+}) {
+ const history = useHistory();
+ const { formatMessage } = useIntl();
+ const [deleteBill, setDeleteBill] = useState(false);
+ const [selectedRows, setSelectedRows] = useState([]);
+
+ useEffect(() => {
+ changePageTitle(formatMessage({ id: 'bill_list' }));
+ }, [changePageTitle, formatMessage]);
+
+ const fetchResourceViews = useQuery(
+ ['resource-views', 'bills'],
+ (key, resourceName) => requestFetchResourceViews(resourceName),
+ );
+
+ const fetchResourceFields = useQuery(
+ ['resource-fields', 'bills'],
+ (key, resourceName) => requestFetchResourceFields(resourceName),
+ );
+
+ const fetchBills = useQuery(['bills-table', billsTableQuery], () =>
+ requestFetchBillsTable(),
+ );
+
+ //handle dalete Bill
+ const handleDeleteBill = useCallback(
+ (bill) => {
+ setDeleteBill(bill);
+ },
+ [setDeleteBill],
+ );
+
+ // handle cancel Bill
+ const handleCancelBillDelete = useCallback(() => {
+ setDeleteBill(false);
+ }, [setDeleteBill]);
+
+ // handleConfirm delete invoice
+ const handleConfirmBillDelete = useCallback(() => {
+ requestDeleteBill(deleteBill.id).then(() => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_bill_has_been_successfully_deleted',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setDeleteBill(false);
+ });
+ }, [deleteBill, requestDeleteBill, formatMessage]);
+
+ const handleEditBill = useCallback((bill) => {
+ history.push(`/bills/${bill.id}/edit`);
+ });
+
+ const handleFetchData = useCallback(
+ ({ pageIndex, pageSize, sortBy }) => {
+ const page = pageIndex + 1;
+
+ addBillsTableQueries({
+ ...(sortBy.length > 0
+ ? {
+ column_sort_by: sortBy[0].id,
+ sort_order: sortBy[0].desc ? 'desc' : 'asc',
+ }
+ : {}),
+ page_size: pageSize,
+ page,
+ });
+ },
+ [addBillsTableQueries],
+ );
+
+ // Handle selected rows change.
+ const handleSelectedRowsChange = useCallback(
+ (_invoices) => {
+ setSelectedRows(_invoices);
+ },
+ [setSelectedRows],
+ );
+
+ // Handle filter change to re-fetch data-table.
+ const handleFilterChanged = useCallback(
+ (filterConditions) => {
+ addBillsTableQueries({
+ filter_roles: filterConditions || '',
+ });
+ },
+ [fetchBills],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+ }
+ confirmButtonText={}
+ icon={'trash'}
+ intent={Intent.DANGER}
+ isOpen={deleteBill}
+ onCancel={handleCancelBillDelete}
+ onConfirm={handleConfirmBillDelete}
+ >
+
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withResourceActions,
+ withBillActions,
+ withDashboardActions,
+ withViewsActions,
+ withBills(({ billsTableQuery }) => ({
+ billsTableQuery,
+ })),
+)(BillList);
diff --git a/client/src/containers/Purchases/Bill/BillViewTabs.js b/client/src/containers/Purchases/Bill/BillViewTabs.js
new file mode 100644
index 000000000..aae359dbc
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/BillViewTabs.js
@@ -0,0 +1,110 @@
+import React, { useEffect, useRef } from 'react';
+import { useHistory } from 'react-router';
+import { Alignment, Navbar, NavbarGroup } from '@blueprintjs/core';
+import { useParams, withRouter } from 'react-router-dom';
+import { connect } from 'react-redux';
+import { pick, debounce } from 'lodash';
+
+import { DashboardViewsTabs } from 'components';
+import { useUpdateEffect } from 'hooks';
+
+import withBills from './withBills';
+import withBillActions from './withBillActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+
+import { compose } from 'utils';
+
+function BillViewTabs({
+ //#withBills
+ billsViews,
+
+ // #withViewDetails
+ viewItem,
+
+ //#withBillActions
+ changeBillView,
+ addBillsTableQueries,
+
+ // #withDashboardActions
+ setTopbarEditView,
+ changePageSubtitle,
+
+ // #ownProps
+ customViewChanged,
+ onViewChanged,
+}) {
+ const history = useHistory();
+ const { custom_view_id: customViewId = null } = useParams();
+
+ useEffect(() => {
+ changeBillView(customViewId || -1);
+ setTopbarEditView(customViewId);
+ changePageSubtitle(customViewId && viewItem ? viewItem.name : '');
+
+ addBillsTableQueries({
+ custom_view_id: customViewId,
+ });
+ return () => {
+ setTopbarEditView(null);
+ changePageSubtitle('');
+ changeBillView(null);
+ };
+ }, [customViewId, addBillsTableQueries, changeBillView]);
+
+ useUpdateEffect(() => {
+ onViewChanged && onViewChanged(customViewId);
+ }, [customViewId]);
+
+ const debounceChangeHistory = useRef(
+ debounce((toUrl) => {
+ history.push(toUrl);
+ }, 250),
+ );
+ // Handle click a new view tab.
+ const handleClickNewView = () => {
+ setTopbarEditView(null);
+ history.push('/custom_views/invoices/new');
+ };
+ const handleTabsChange = (viewId) => {
+ const toPath = viewId ? `${viewId}/custom_view` : '';
+ debounceChangeHistory.current(`/bills/${toPath}`);
+ setTopbarEditView(viewId);
+ };
+ const tabs = billsViews.map((view) => ({
+ ...pick(view, ['name', 'id']),
+ }));
+
+ console.log(billsViews, 'billsViews');
+
+ return (
+
+
+
+
+
+ );
+}
+
+const mapStateToProps = (state, ownProps) => ({
+ viewId: ownProps.match.params.custom_view_id,
+});
+
+const withBillsViewTabs = connect(mapStateToProps);
+
+export default compose(
+ withRouter,
+ withBillsViewTabs,
+ withBillActions,
+ withDashboardActions,
+ withViewDetails(),
+ withBills(({ billsViews }) => ({
+ billsViews,
+ })),
+)(BillViewTabs);
diff --git a/client/src/containers/Purchases/Bill/Bills.js b/client/src/containers/Purchases/Bill/Bills.js
new file mode 100644
index 000000000..c336a3e74
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/Bills.js
@@ -0,0 +1,85 @@
+import React, { useCallback } from 'react';
+import { useParams, useHistory } from 'react-router-dom';
+import { useQuery } from 'react-query';
+
+import BillForm from './BillForm';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import withVendorActions from 'containers/Vendors/withVendorActions';
+import withAccountsActions from 'containers/Accounts/withAccountsActions';
+import withItemsActions from 'containers/Items/withItemsActions';
+import withBillActions from './withBillActions';
+
+import { compose } from 'utils';
+
+function Bills({
+ //#withwithAccountsActions
+ requestFetchAccounts,
+
+ //#withVendorActions
+ requestFetchVendorsTable,
+
+ //#withItemsActions
+ requestFetchItems,
+
+ //# withBilleActions
+ requestFetchBill,
+}) {
+ const history = useHistory();
+ const { id } = useParams();
+
+ // Handle fetch accounts
+ const fetchAccounts = useQuery('accounts-list', (key) =>
+ requestFetchAccounts(),
+ );
+
+ // Handle fetch customers data table
+ const fetchVendors = useQuery('vendors-list', () =>
+ requestFetchVendorsTable({}),
+ );
+
+ // Handle fetch Items data table or list
+ const fetchItems = useQuery('items-list', () => requestFetchItems({}));
+
+ const handleFormSubmit = useCallback(
+ (payload) => {
+ payload.redirect && history.push('/bills');
+ },
+ [history],
+ );
+
+ const handleCancel = useCallback(() => {
+ history.goBack();
+ }, [history]);
+
+ const fetchBill = useQuery(
+ ['bill', id],
+ (key, _id) => requestFetchBill(_id),
+ { enabled: !!id },
+ );
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withBillActions,
+ withVendorActions,
+ withItemsActions,
+ withAccountsActions,
+)(Bills);
diff --git a/client/src/containers/Purchases/Bill/BillsDataTable.js b/client/src/containers/Purchases/Bill/BillsDataTable.js
new file mode 100644
index 000000000..c6df6b92a
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/BillsDataTable.js
@@ -0,0 +1,243 @@
+import React, { useEffect, useCallback, useState, useMemo } from 'react';
+import {
+ Intent,
+ Button,
+ Classes,
+ Popover,
+ Menu,
+ MenuItem,
+ MenuDivider,
+ Position,
+} from '@blueprintjs/core';
+
+import { useParams } from 'react-router-dom';
+import { withRouter } from 'react-router';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import moment from 'moment';
+
+import Icon from 'components/Icon';
+import { compose } from 'utils';
+import { useUpdateEffect } from 'hooks';
+
+import LoadingIndicator from 'components/LoadingIndicator';
+import DataTable from 'components/DataTable';
+
+import withDialogActions from 'containers/Dialog/withDialogActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+
+import withBills from './withBills';
+import withBillActions from './withBillActions';
+import withCurrentView from 'containers/Views/withCurrentView';
+
+function BillsDataTable({
+ //#withBills
+ billsCurrentPage,
+ billsLoading,
+ billsPageination,
+
+ // #withDashboardActions
+ changeCurrentView,
+ changePageSubtitle,
+ setTopbarEditView,
+
+ // #withView
+ viewMeta,
+
+ //#OwnProps
+ loading,
+ onFetchData,
+ onEditBill,
+ onDeleteBill,
+ onSelectedRowsChange,
+}) {
+ const [initialMount, setInitialMount] = useState(false);
+ const { custom_view_id: customViewId } = useParams();
+ const { formatMessage } = useIntl();
+
+ useEffect(() => {
+ setInitialMount(false);
+ }, [customViewId]);
+
+ useUpdateEffect(() => {
+ if (!billsLoading) {
+ setInitialMount(true);
+ }
+ }, [billsLoading, setInitialMount]);
+
+ useEffect(() => {
+ if (customViewId) {
+ changeCurrentView(customViewId);
+ setTopbarEditView(customViewId);
+ }
+ changePageSubtitle(customViewId && viewMeta ? viewMeta.name : '');
+ }, [
+ customViewId,
+ changeCurrentView,
+ changePageSubtitle,
+ setTopbarEditView,
+ viewMeta,
+ ]);
+
+ const handleEditBill = useCallback(
+ (_bill) => () => {
+ onEditBill && onEditBill(_bill);
+ },
+ [onEditBill],
+ );
+
+ const handleDeleteBill = useCallback(
+ (_bill) => () => {
+ onDeleteBill && onDeleteBill(_bill);
+ },
+ [onDeleteBill],
+ );
+
+ const actionMenuList = useCallback(
+ (bill) => (
+
+ ),
+ [handleDeleteBill, handleEditBill, formatMessage],
+ );
+
+ const onRowContextMenu = useCallback(
+ (cell) => {
+ return actionMenuList(cell.row.original);
+ },
+ [actionMenuList],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'bill_date',
+ Header: formatMessage({ id: 'bill_date' }),
+ accessor: (r) => moment(r.bill_date).format('YYYY MMM DD'),
+ width: 140,
+ className: 'bill_date',
+ },
+ {
+ id: 'vendor_id',
+ Header: formatMessage({ id: 'vendor_name' }),
+ accessor: 'vendor.display_name',
+ width: 140,
+ className: 'vendor_id',
+ },
+ {
+ id: 'bill_number',
+ Header: formatMessage({ id: 'bill_number' }),
+ accessor: (row) => `#${row.bill_number}`,
+ width: 140,
+ className: 'bill_number',
+ },
+
+ {
+ id: 'due_date',
+ Header: formatMessage({ id: 'due_date' }),
+ accessor: (r) => moment(r.due_date).format('YYYY MMM DD'),
+ width: 140,
+ className: 'due_date',
+ },
+ {
+ id: 'amount',
+ Header: formatMessage({ id: 'amount' }),
+ accessor: 'amount',
+ width: 140,
+ className: 'amount',
+ },
+ {
+ id: 'reference_no',
+ Header: formatMessage({ id: 'reference_no' }),
+ accessor: 'reference_no',
+ width: 140,
+ className: 'reference_no',
+ },
+ {
+ id: 'status',
+ Header: formatMessage({ id: 'status' }),
+ accessor: 'status',
+ width: 140,
+ className: 'status',
+ },
+ {
+ id: 'actions',
+ Header: '',
+ Cell: ({ cell }) => (
+
+ } />
+
+ ),
+ className: 'actions',
+ width: 50,
+ disableResizing: true,
+ },
+ ],
+ [actionMenuList, formatMessage],
+ );
+
+ const handleDataTableFetchData = useCallback(
+ (...args) => {
+ onFetchData && onFetchData(...args);
+ },
+ [onFetchData],
+ );
+ const handleSelectedRowsChange = useCallback(
+ (selectedRows) => {
+ onSelectedRowsChange &&
+ onSelectedRowsChange(selectedRows.map((s) => s.original));
+ },
+ [onSelectedRowsChange],
+ );
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withRouter,
+ withCurrentView,
+ withDialogActions,
+ withDashboardActions,
+ withBillActions,
+ withBills(({ billsCurrentPage, billsLoading, billsPageination }) => ({
+ billsCurrentPage,
+ billsLoading,
+ billsPageination,
+ })),
+ withViewDetails(),
+)(BillsDataTable);
diff --git a/client/src/containers/Purchases/Bill/withBillActions.js b/client/src/containers/Purchases/Bill/withBillActions.js
new file mode 100644
index 000000000..83bd057d7
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/withBillActions.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import {
+ submitBill,
+ deleteBill,
+ editBill,
+ fetchBillsTable,
+ fetchBill,
+} from 'store/Bills/bills.actions';
+import t from 'store/types';
+
+const mapDispatchToProps = (dispatch) => ({
+ requestSubmitBill: (form) => dispatch(submitBill({ form })),
+ requestFetchBill: (id) => dispatch(fetchBill({ id })),
+ requestEditBill: (id, form) => dispatch(editBill( id, form )),
+ requestDeleteBill: (id) => dispatch(deleteBill({ id })),
+ requestFetchBillsTable: (query = {}) =>
+ dispatch(fetchBillsTable({ query: { ...query } })),
+
+ changeBillView: (id) =>
+ dispatch({
+ type: t.BILLS_SET_CURRENT_VIEW,
+ currentViewId: parseInt(id, 10),
+ }),
+
+ addBillsTableQueries: (queries) =>
+ dispatch({
+ type: t.BILLS_TABLE_QUERIES_ADD,
+ queries,
+ }),
+});
+
+export default connect(null, mapDispatchToProps);
diff --git a/client/src/containers/Purchases/Bill/withBillDetail.js b/client/src/containers/Purchases/Bill/withBillDetail.js
new file mode 100644
index 000000000..3170ee5d5
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/withBillDetail.js
@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import { getBillByIdFactory } from 'store/Bills/bills.selectors';
+
+export default () => {
+ const getBillById = getBillByIdFactory();
+
+ const mapStateToProps = (state, props) => ({
+ bill: getBillById(state, props),
+ });
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Purchases/Bill/withBills.js b/client/src/containers/Purchases/Bill/withBills.js
new file mode 100644
index 000000000..6469c5cdc
--- /dev/null
+++ b/client/src/containers/Purchases/Bill/withBills.js
@@ -0,0 +1,30 @@
+import { connect } from 'react-redux';
+import { getResourceViews } from 'store/customViews/customViews.selectors';
+import {
+ getBillCurrentPageFactory,
+ getBillPaginationMetaFactory,
+ getBillTableQueryFactory,
+} from 'store/Bills/bills.selectors';
+
+export default (mapState) => {
+ const getBillsItems = getBillCurrentPageFactory();
+ const getBillsPaginationMeta = getBillPaginationMetaFactory();
+ const getBillTableQuery = getBillTableQueryFactory();
+
+ const mapStateToProps = (state, props) => {
+ const tableQuery = getBillTableQuery(state, props);
+
+ const mapped = {
+ billsCurrentPage: getBillsItems(state, props, tableQuery),
+ billsViews: getResourceViews(state, props, 'bills'),
+ billsItems: state.bills.items,
+ billsTableQuery: tableQuery,
+
+ // @todo un-unncessery shit.
+ billsPageination: getBillsPaginationMeta(state, props, tableQuery),
+ billsLoading: state.bills.loading,
+ };
+ return mapState ? mapState(mapped, state, props) : mapped;
+ };
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Purchases/BillForm.js b/client/src/containers/Purchases/BillForm.js
new file mode 100644
index 000000000..fce4b0ae4
--- /dev/null
+++ b/client/src/containers/Purchases/BillForm.js
@@ -0,0 +1,299 @@
+import React, {
+ useMemo,
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+} from 'react';
+import * as Yup from 'yup';
+import { useFormik } from 'formik';
+import moment from 'moment';
+import { Intent, FormGroup, TextArea } from '@blueprintjs/core';
+
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import { pick, omit } from 'lodash';
+
+import BillFormHeader from './BillFormHeader';
+import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable';
+import BillFormFooter from './BillFormFooter';
+
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withMediaActions from 'containers/Media/withMediaActions';
+import withBillActions from './withBillActions';
+
+import { AppToaster } from 'components';
+import Dragzone from 'components/Dragzone';
+import useMedia from 'hooks/useMedia';
+
+import { compose, repeatValue } from 'utils';
+
+const MIN_LINES_NUMBER = 4;
+
+function BillForm({
+ //#WithMedia
+ requestSubmitMedia,
+ requestDeleteMedia,
+
+ //#withBillActions
+ requestSubmitBill,
+
+ //#withDashboard
+ changePageTitle,
+ changePageSubtitle,
+
+ //#withBillDetail
+ bill,
+
+ //#Own Props
+ onFormSubmit,
+ onCancelForm,
+}) {
+ const { formatMessage } = useIntl();
+ const [payload, setPayload] = useState({});
+
+ const {
+ setFiles,
+ saveMedia,
+ deletedFiles,
+ setDeletedFiles,
+ deleteMedia,
+ } = useMedia({
+ saveCallback: requestSubmitMedia,
+ deleteCallback: requestDeleteMedia,
+ });
+
+ const handleDropFiles = useCallback((_files) => {
+ setFiles(_files.filter((file) => file.uploaded === false));
+ }, []);
+
+ const savedMediaIds = useRef([]);
+ const clearSavedMediaIds = () => {
+ savedMediaIds.current = [];
+ };
+
+ useEffect(() => {
+ if (bill && bill.id) {
+ changePageTitle(formatMessage({ id: 'edit_bill' }));
+ } else {
+ changePageTitle(formatMessage({ id: 'new_bill' }));
+ }
+ });
+
+ const validationSchema = Yup.object().shape({
+ vendor_id: Yup.number()
+ .required()
+ .label(formatMessage({ id: 'vendor_name_' })),
+ bill_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'bill_date_' })),
+ due_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'due_date_' })),
+ bill_number: Yup.number()
+ .required()
+ .label(formatMessage({ id: 'bill_number_' })),
+ reference_no: Yup.string().min(1).max(255),
+ status: Yup.string().required(),
+ note: Yup.string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .label(formatMessage({ id: 'note' })),
+
+ entries: Yup.array().of(
+ Yup.object().shape({
+ quantity: Yup.number().nullable(),
+ rate: Yup.number().nullable(),
+ item_id: Yup.number()
+ .nullable()
+ .when(['quantity', 'rate'], {
+ is: (quantity, rate) => quantity || rate,
+ then: Yup.number().required(),
+ }),
+ total: Yup.number().nullable(),
+ discount: Yup.number().nullable(),
+ description: Yup.string().nullable(),
+ }),
+ ),
+ });
+
+ const saveBillSubmit = useCallback(
+ (payload) => {
+ onFormSubmit && onFormSubmit(payload);
+ },
+ [onFormSubmit],
+ );
+
+ const defaultBill = useMemo(() => ({
+ index: 0,
+ item_id: null,
+ rate: null,
+ discount: null,
+ quantity: null,
+ description: '',
+ status: '',
+ }));
+
+ const defaultInitialValues = useMemo(
+ () => ({
+ accept: '',
+ vendor_name: '',
+ bill_number: '',
+ bill_date: moment(new Date()).format('YYYY-MM-DD'),
+ due_date: moment(new Date()).format('YYYY-MM-DD'),
+ reference_no: '',
+ note: '',
+ entries: [...repeatValue(defaultBill, MIN_LINES_NUMBER)],
+ }),
+ [defaultBill],
+ );
+
+ const orderingIndex = (_invoice) => {
+ return _invoice.map((item, index) => ({
+ ...item,
+ index: index + 1,
+ }));
+ };
+
+ const initialValues = useMemo(
+ () => ({
+ ...defaultInitialValues,
+ entries: orderingIndex(defaultInitialValues.entries),
+ }),
+ [defaultInitialValues],
+ );
+
+ const initialAttachmentFiles = useMemo(() => {
+ return bill && bill.media
+ ? bill.media.map((attach) => ({
+ preview: attach.attachment_file,
+ uploaded: true,
+ metadata: { ...attach },
+ }))
+ : [];
+ }, [bill]);
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ validationSchema,
+ initialValues: {
+ ...initialValues,
+ },
+ onSubmit: async (values, { setSubmitting, setErrors, resetForm }) => {
+ setSubmitting(true);
+ const entries = values.entries.map((item) => omit(item, ['total']));
+
+ const form = {
+ ...values,
+ entries,
+ };
+ const saveBill = (mediaIds) =>
+ new Promise((resolve, reject) => {
+ const requestForm = { ...form, media_ids: mediaIds };
+
+ requestSubmitBill(requestForm)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage(
+ { id: 'the_bill_has_been_successfully_created' },
+ { number: values.bill_number },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ resetForm();
+ saveBillSubmit({ action: 'new', ...payload });
+ clearSavedMediaIds();
+ })
+ .catch((errors) => {
+ setSubmitting(false);
+ });
+ });
+
+ Promise.all([saveMedia(), deleteMedia()])
+ .then(([savedMediaResponses]) => {
+ const mediaIds = savedMediaResponses.map((res) => res.data.media.id);
+ savedMediaIds.current = mediaIds;
+ return savedMediaResponses;
+ })
+ .then(() => {
+ return saveBill(savedMediaIds.current);
+ });
+ },
+ });
+
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPayload(payload);
+ formik.submitForm();
+ },
+ [setPayload, formik],
+ );
+
+ const handleCancelClick = useCallback(
+ (payload) => {
+ onCancelForm && onCancelForm(payload);
+ },
+ [onCancelForm],
+ );
+
+ console.log(formik.errors, 'Bill');
+ const handleDeleteFile = useCallback(
+ (_deletedFiles) => {
+ _deletedFiles.forEach((deletedFile) => {
+ if (deletedFile.uploaded && deletedFile.metadata.id) {
+ setDeletedFiles([...deletedFiles, deletedFile.metadata.id]);
+ }
+ });
+ },
+ [setDeletedFiles, deletedFiles],
+ );
+
+ const onClickCleanAllLines = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingIndex([...repeatValue(defaultBill, MIN_LINES_NUMBER)]),
+ );
+ };
+
+ const onClickAddNewRow = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingIndex([...formik.values.entries, defaultBill]),
+ );
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withBillActions,
+ withDashboardActions,
+ withMediaActions,
+)(BillForm);
diff --git a/client/src/containers/Purchases/BillFormFooter.js b/client/src/containers/Purchases/BillFormFooter.js
new file mode 100644
index 000000000..badfa25d3
--- /dev/null
+++ b/client/src/containers/Purchases/BillFormFooter.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import { Intent, Button } from '@blueprintjs/core';
+import { FormattedMessage as T } from 'react-intl';
+
+export default function BillFormFooter({
+ formik: { isSubmitting },
+ onSubmitClick,
+ onCancelClick,
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/Purchases/BillFormHeader.js b/client/src/containers/Purchases/BillFormHeader.js
new file mode 100644
index 000000000..27b356217
--- /dev/null
+++ b/client/src/containers/Purchases/BillFormHeader.js
@@ -0,0 +1,185 @@
+import React, { useMemo, useCallback, useState } from 'react';
+import {
+ FormGroup,
+ InputGroup,
+ Intent,
+ Position,
+ MenuItem,
+ Classes,
+} from '@blueprintjs/core';
+import { DateInput } from '@blueprintjs/datetime';
+import { FormattedMessage as T } from 'react-intl';
+import { Row, Col } from 'react-grid-system';
+import moment from 'moment';
+import { momentFormatter, compose, tansformDateValue } from 'utils';
+import classNames from 'classnames';
+import {
+ AccountsSelectList,
+ ListSelect,
+ ErrorMessage,
+ FieldRequiredHint,
+ Hint,
+} from 'components';
+
+import withCustomers from 'containers/Customers/withCustomers';
+import withAccounts from 'containers/Accounts/withAccounts';
+
+function BillFormHeader({
+ formik: { errors, touched, setFieldValue, getFieldProps, values },
+
+ //#withCustomers
+ customers,
+ //#withAccouts
+ accountsList,
+}) {
+ const handleDateChange = useCallback(
+ (date_filed) => (date) => {
+ const formatted = moment(date).format('YYYY-MM-DD');
+ setFieldValue(date_filed, formatted);
+ },
+ [setFieldValue],
+ );
+
+ const onChangeSelected = useCallback(
+ (filedName) => {
+ return (item) => {
+ setFieldValue(filedName, item.id);
+ };
+ },
+ [setFieldValue],
+ );
+
+ const vendorNameRenderer = useCallback(
+ (accept, { handleClick }) => (
+
+ ),
+ [],
+ );
+
+ // Filter vendor name
+ const filterVendorAccount = (query, vendor, _index, exactMatch) => {
+ const normalizedTitle = vendor.display_name.toLowerCase();
+ const normalizedQuery = query.toLowerCase();
+ if (exactMatch) {
+ return normalizedTitle === normalizedQuery;
+ } else {
+ return (
+ `${vendor.display_name} ${normalizedTitle}`.indexOf(normalizedQuery) >=
+ 0
+ );
+ }
+ };
+
+ return (
+
+
+ {/* vendor account name */}
+ }
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ labelInfo={}
+ intent={errors.vendor_id && touched.vendor_id && Intent.DANGER}
+ helperText={
+
+ }
+ >
+ }
+ itemRenderer={vendorNameRenderer}
+ itemPredicate={filterVendorAccount}
+ popoverProps={{ minimal: true }}
+ onItemSelect={onChangeSelected('vendor_id')}
+ selectedItem={values.vendor_id}
+ selectedItemProp={'id'}
+ defaultText={}
+ labelProp={'display_name'}
+ />
+
+
+
+ }
+ inline={true}
+ labelInfo={}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={errors.bill_date && touched.bill_date && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+
+ }
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={errors.due_date && touched.due_date && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+
+ {/* bill number */}
+ }
+ inline={true}
+ className={('form-group--estimate', Classes.FILL)}
+ labelInfo={}
+ intent={errors.bill_number && touched.bill_number && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+
}
+ inline={true}
+ className={classNames('form-group--reference', Classes.FILL)}
+ intent={errors.reference_no && touched.reference_no && Intent.DANGER}
+ helperText={
}
+ >
+
+
+
+ );
+}
+
+export default compose(
+ withCustomers(({ customers }) => ({
+ customers,
+ })),
+ withAccounts(({ accountsList }) => ({
+ accountsList,
+ })),
+)(BillFormHeader);
diff --git a/client/src/containers/Purchases/Bills.js b/client/src/containers/Purchases/Bills.js
new file mode 100644
index 000000000..24555328c
--- /dev/null
+++ b/client/src/containers/Purchases/Bills.js
@@ -0,0 +1,62 @@
+import React, { useCallback } from 'react';
+import { useParams, useHistory } from 'react-router-dom';
+import { useQuery } from 'react-query';
+
+import BillForm from './BillForm';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import withCustomersActions from 'containers/Customers/withCustomersActions';
+import withAccountsActions from 'containers/Accounts/withAccountsActions';
+import withItemsActions from 'containers/Items/withItemsActions';
+
+import { compose } from 'utils';
+
+function Bills({
+ //#withwithAccountsActions
+ requestFetchAccounts,
+
+ //#withCustomersActions
+ requestFetchCustomers,
+
+ //#withItemsActions
+ requestFetchItems,
+}) {
+ const history = useHistory();
+
+ // Handle fetch accounts
+ const fetchAccounts = useQuery('accounts-list', (key) =>
+ requestFetchAccounts(),
+ );
+
+ // Handle fetch customers data table
+ const fetchCustomers = useQuery('customers-table', () =>
+ requestFetchCustomers({}),
+ );
+
+ // Handle fetch Items data table or list
+ const fetchItems = useQuery('items-table', () => requestFetchItems({}));
+
+ const handleFormSubmit = useCallback((payload) => {}, [history]);
+
+ const handleCancel = useCallback(() => {
+ history.goBack();
+ }, [history]);
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withCustomersActions,
+ withItemsActions,
+ withAccountsActions,
+)(Bills);
diff --git a/client/src/containers/Purchases/withBillActions.js b/client/src/containers/Purchases/withBillActions.js
new file mode 100644
index 000000000..65d982e7b
--- /dev/null
+++ b/client/src/containers/Purchases/withBillActions.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import {
+ submitBill,
+ deleteBill,
+ editBill,
+ fetchBillsTable,
+ fetchBill,
+} from 'store/Bills/bills.actions';
+import t from 'store/types';
+
+const mapDispatchToProps = (dispatch) => ({
+ requestSubmitBill: (form) => dispatch(submitBill({ form })),
+ requestFetchBill: (id) => dispatch(fetchBill({ id })),
+ requestEditBill: (id, form) => dispatch(editBill({ id, form })),
+ requestDeleteBill: (id) => dispatch(deleteBill({ id })),
+ requestFetchBillsTable: (query = {}) =>
+ dispatch(fetchBillsTable({ query: { ...query } })),
+
+ changeBillView: (id) =>
+ dispatch({
+ type: t.BILL_SET_CURRENT_VIEW,
+ currentViewId: parseInt(id, 10),
+ }),
+
+ addBillsTableQueries: (queries) =>
+ dispatch({
+ type: t.BILLS_TABLE_QUERIES_ADD,
+ queries,
+ }),
+});
+
+export default connect(null, mapDispatchToProps);
diff --git a/client/src/containers/Sales/Estimate/EntriesItemsTable.js b/client/src/containers/Sales/Estimate/EntriesItemsTable.js
new file mode 100644
index 000000000..ab7e2f266
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EntriesItemsTable.js
@@ -0,0 +1,259 @@
+import React, { useState, useMemo, useEffect, useCallback } from 'react';
+import { Button, Intent, Position, Tooltip } from '@blueprintjs/core';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import DataTable from 'components/DataTable';
+import Icon from 'components/Icon';
+
+import { compose, formattedAmount } from 'utils';
+import {
+ InputGroupCell,
+ MoneyFieldCell,
+ EstimatesListFieldCell,
+ PercentFieldCell,
+ DivFieldCell,
+} from 'components/DataTableCells';
+
+import withItems from 'containers/Items/withItems';
+import { omit } from 'lodash';
+
+const ActionsCellRenderer = ({
+ row: { index },
+ column: { id },
+ cell: { value },
+ data,
+ payload,
+}) => {
+ if (data.length <= index + 1) {
+ return '';
+ }
+ const onRemoveRole = () => {
+ payload.removeRow(index);
+ };
+ return (
+ } position={Position.LEFT}>
+ }
+ iconSize={14}
+ className="m12"
+ intent={Intent.DANGER}
+ onClick={onRemoveRole}
+ />
+
+ );
+};
+
+const TotalEstimateCellRederer = (content, type) => (props) => {
+ if (props.data.length === props.row.index + 1) {
+ const total = props.data.reduce((total, entry) => {
+ const amount = parseInt(entry[type], 10);
+ const computed = amount ? total + amount : total;
+
+ return computed;
+ }, 0);
+ return {formattedAmount(total, 'USD')};
+ }
+ return content(props);
+};
+
+const calculateDiscount = (discount, quantity, rate) =>
+ quantity * rate - (quantity * rate * discount) / 100;
+
+const CellRenderer = (content, type) => (props) => {
+ if (props.data.length === props.row.index + 1) {
+ return '';
+ }
+ return content(props);
+};
+
+function EstimateTable({
+ //#withitems
+ itemsCurrentPage,
+
+ //#ownProps
+ onClickRemoveRow,
+ onClickAddNewRow,
+ onClickClearAllLines,
+ entries,
+ formik: { errors, setFieldValue, values },
+}) {
+ const [rows, setRows] = useState([]);
+ const { formatMessage } = useIntl();
+
+ useEffect(() => {
+ setRows([...entries.map((e) => ({ ...e }))]);
+ }, [entries]);
+
+ const columns = useMemo(
+ () => [
+ {
+ Header: '#',
+ accessor: 'index',
+ Cell: ({ row: { index } }) => {index + 1},
+ width: 40,
+ disableResizing: true,
+ disableSortBy: true,
+ },
+ {
+ Header: formatMessage({ id: 'product_and_service' }),
+ id: 'item_id',
+ accessor: 'item_id',
+ Cell: EstimatesListFieldCell,
+ disableSortBy: true,
+ disableResizing: true,
+ width: 250,
+ },
+ {
+ Header: formatMessage({ id: 'description' }),
+ accessor: 'description',
+ Cell: InputGroupCell,
+ disableSortBy: true,
+ className: 'description',
+ },
+
+ {
+ Header: formatMessage({ id: 'quantity' }),
+ accessor: 'quantity',
+ Cell: CellRenderer(InputGroupCell, 'quantity'),
+ disableSortBy: true,
+ width: 100,
+ className: 'quantity',
+ },
+ {
+ Header: formatMessage({ id: 'rate' }),
+ accessor: 'rate',
+ Cell: TotalEstimateCellRederer(MoneyFieldCell, 'rate'),
+ disableSortBy: true,
+ width: 150,
+ className: 'rate',
+ },
+ {
+ Header: formatMessage({ id: 'discount' }),
+ accessor: 'discount',
+ Cell: CellRenderer(PercentFieldCell, InputGroupCell),
+ disableSortBy: true,
+ disableResizing: true,
+ width: 100,
+ className: 'discount',
+ },
+ {
+ Header: formatMessage({ id: 'total' }),
+ accessor: (row) =>
+ calculateDiscount(row.discount, row.quantity, row.rate),
+ Cell: TotalEstimateCellRederer(DivFieldCell, 'total'),
+ disableSortBy: true,
+ width: 150,
+ className: 'total',
+ },
+ {
+ Header: '',
+ accessor: 'action',
+ Cell: ActionsCellRenderer,
+ className: 'actions',
+ disableSortBy: true,
+ disableResizing: true,
+ width: 45,
+ },
+ ],
+ [formatMessage],
+ );
+
+ const handleUpdateData = useCallback(
+ (rowIndex, columnId, value) => {
+ const newRow = rows.map((row, index) => {
+ if (index === rowIndex) {
+ const newRow = { ...rows[rowIndex], [columnId]: value };
+ return {
+ ...newRow,
+ total: calculateDiscount(
+ newRow.discount,
+ newRow.quantity,
+ newRow.rate,
+ ),
+ };
+ }
+ return row;
+ });
+ setFieldValue(
+ 'entries',
+ newRow.map((row) => ({
+ ...omit(row, ['total']),
+ })),
+ );
+ },
+ [rows, setFieldValue],
+ );
+
+ const handleRemoveRow = useCallback(
+ (rowIndex) => {
+ if (rows.length <= 1) {
+ return;
+ }
+
+ const removeIndex = parseInt(rowIndex, 10);
+ const newRows = rows.filter((row, index) => index !== removeIndex);
+ setFieldValue(
+ 'entries',
+ newRows.map((row, index) => ({
+ ...omit(row),
+ index: index + 1,
+ })),
+ );
+ onClickRemoveRow && onClickRemoveRow(removeIndex);
+ },
+ [rows, setFieldValue, onClickRemoveRow],
+ );
+
+ const onClickNewRow = () => {
+ onClickAddNewRow && onClickAddNewRow();
+ };
+
+ const handleClickClearAllLines = () => {
+ onClickClearAllLines && onClickClearAllLines();
+ };
+
+ const rowClassNames = useCallback(
+ (row) => ({
+ 'row--total': rows.length === row.index + 1,
+ }),
+ [rows],
+ );
+
+ return (
+
+ );
+}
+
+export default compose(
+ withItems(({ itemsCurrentPage }) => ({
+ itemsCurrentPage,
+ })),
+)(EstimateTable);
diff --git a/client/src/containers/Sales/Estimate/EstimateActionsBar.js b/client/src/containers/Sales/Estimate/EstimateActionsBar.js
new file mode 100644
index 000000000..7f6c6abbc
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateActionsBar.js
@@ -0,0 +1,168 @@
+import React, { useMemo, useCallback, useState } from 'react';
+import Icon from 'components/Icon';
+import {
+ Button,
+ Classes,
+ Menu,
+ MenuItem,
+ Popover,
+ NavbarDivider,
+ NavbarGroup,
+ PopoverInteractionKind,
+ Position,
+ Intent,
+} from '@blueprintjs/core';
+import classNames from 'classnames';
+import { useRouteMatch, useHistory } from 'react-router-dom';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+
+import { If, DashboardActionViewsList } from 'components';
+import FilterDropdown from 'components/FilterDropdown';
+import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar';
+
+import withResourceDetail from 'containers/Resources/withResourceDetails';
+import withDialogActions from 'containers/Dialog/withDialogActions';
+import withEstimateActions from './withEstimateActions';
+
+import withEstimates from './withEstimates';
+
+import { compose } from 'utils';
+import { connect } from 'react-redux';
+
+function EstimateActionsBar({
+ // #withResourceDetail
+ resourceFields,
+
+ //#withEstimates
+ estimateViews,
+
+ // #withEstimateActions
+ addEstimatesTableQueries,
+
+ // #own Porps
+ onFilterChanged,
+ selectedRows = [],
+}) {
+ const { path } = useRouteMatch();
+ const history = useHistory();
+ const [filterCount, setFilterCount] = useState(0);
+ const { formatMessage } = useIntl();
+
+ const onClickNewEstimate = useCallback(() => {
+ history.push('/estimates/new');
+ }, [history]);
+
+ // const filterDropdown = FilterDropdown({
+ // fields: resourceFields,
+ // initialCondition: {
+ // fieldKey: 'estimate_number',
+ // compatator: 'contains',
+ // value: '',
+ // },
+ // onFilterChange: (filterConditions) => {
+ // setFilterCount(filterConditions.length || 0);
+ // addEstimatesTableQueries({
+ // filter_roles: filterConditions || '',
+ // });
+ // onFilterChanged && onFilterChanged(filterConditions);
+ // },
+ // });
+
+ const hasSelectedRows = useMemo(() => selectedRows.length > 0, [
+ selectedRows,
+ ]);
+
+ const viewsMenuItems = estimateViews.map((view) => {
+ return (
+
+ );
+ });
+
+ return (
+
+
+ {viewsMenuItems}}
+ minimal={true}
+ interactionKind={PopoverInteractionKind.HOVER}
+ position={Position.BOTTOM_LEFT}
+ >
+ }
+ text={}
+ rightIcon={'caret-down'}
+ />
+
+
+
+ }
+ text={}
+ onClick={onClickNewEstimate}
+ />
+
+
+ ) : (
+ `${filterCount} ${formatMessage({ id: 'filters_applied' })}`
+ )
+ }
+ icon={}
+ />
+
+
+ }
+ text={}
+ intent={Intent.DANGER}
+ // onClick={handleBulkDelete}
+ />
+
+ }
+ text={}
+ />
+
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+
+
+ );
+}
+
+const mapStateToProps = (state, props) => ({
+ resourceName: 'sales_estimates',
+});
+const withEstimateActionsBar = connect(mapStateToProps);
+
+export default compose(
+ withEstimateActionsBar,
+ withDialogActions,
+ withEstimates(({ estimateViews }) => ({
+ estimateViews,
+ })),
+ withResourceDetail(({ resourceFields }) => ({
+ resourceFields,
+ })),
+ withEstimateActions,
+)(EstimateActionsBar);
diff --git a/client/src/containers/Sales/Estimate/EstimateForm.js b/client/src/containers/Sales/Estimate/EstimateForm.js
new file mode 100644
index 000000000..040b95e7b
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateForm.js
@@ -0,0 +1,360 @@
+import React, {
+ useMemo,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+
+import * as Yup from 'yup';
+import { useFormik } from 'formik';
+import moment from 'moment';
+import { Intent, FormGroup, TextArea, Button } from '@blueprintjs/core';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import { pick, omit } from 'lodash';
+import { queryCache } from 'react-query';
+
+import EstimateFormHeader from './EstimateFormHeader';
+import EstimatesItemsTable from './EntriesItemsTable';
+import EstimateFormFooter from './EstimateFormFooter';
+
+import withEstimateActions from './withEstimateActions';
+import withEstimateDetail from './withEstimateDetail';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withMediaActions from 'containers/Media/withMediaActions';
+
+import AppToaster from 'components/AppToaster';
+import Dragzone from 'components/Dragzone';
+import useMedia from 'hooks/useMedia';
+
+import { compose, repeatValue } from 'utils';
+
+const MIN_LINES_NUMBER = 4;
+
+const EstimateForm = ({
+ //#WithMedia
+ requestSubmitMedia,
+ requestDeleteMedia,
+
+ //#WithEstimateActions
+ requestSubmitEstimate,
+ requestEditEstimate,
+
+ //#withDashboard
+ changePageTitle,
+ changePageSubtitle,
+
+ //#withEstimateDetail
+ estimate,
+
+ //#own Props
+ estimateId,
+ onFormSubmit,
+ onCancelForm,
+}) => {
+ const { formatMessage } = useIntl();
+ const [payload, setPayload] = useState({});
+
+ const {
+ setFiles,
+ saveMedia,
+ deletedFiles,
+ setDeletedFiles,
+ deleteMedia,
+ } = useMedia({
+ saveCallback: requestSubmitMedia,
+ deleteCallback: requestDeleteMedia,
+ });
+
+ const handleDropFiles = useCallback((_files) => {
+ setFiles(_files.filter((file) => file.uploaded === false));
+ }, []);
+
+ const savedMediaIds = useRef([]);
+ const clearSavedMediaIds = () => {
+ savedMediaIds.current = [];
+ };
+
+ useEffect(() => {
+ if (estimate && estimate.id) {
+ changePageTitle(formatMessage({ id: 'edit_estimate' }));
+ } else {
+ changePageTitle(formatMessage({ id: 'new_estimate' }));
+ }
+ }, [changePageTitle, estimate, formatMessage]);
+
+ const validationSchema = Yup.object().shape({
+ customer_id: Yup.number()
+ .label(formatMessage({ id: 'customer_name_' }))
+ .required(),
+ estimate_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'estimate_date_' })),
+ expiration_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'expiration_date_' })),
+ estimate_number: Yup.number()
+ .required()
+ .nullable()
+ .label(formatMessage({ id: 'estimate_number_' })),
+ reference: Yup.string().min(1).max(255),
+ note: Yup.string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .label(formatMessage({ id: 'note' })),
+
+ terms_conditions: Yup.string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .label(formatMessage({ id: 'note' })),
+
+ entries: Yup.array().of(
+ Yup.object().shape({
+ quantity: Yup.number().nullable(),
+ //Cyclic dependency
+ rate: Yup.number().nullable(),
+ // .when(['item_id'], {
+ // is: (item_id) => item_id,
+ // then: Yup.number().required(),
+ // }),
+
+ // rate: Yup.number().test((value) => {
+ // const { item_id } = this.parent;
+ // if (!item_id) return value != null;
+ // return false;
+ // }),
+ item_id: Yup.number()
+ .nullable()
+ .when(['quantity', 'rate'], {
+ is: (quantity, rate) => quantity || rate,
+ then: Yup.number().required(),
+ }),
+ discount: Yup.number().nullable(),
+ description: Yup.string().nullable(),
+ }),
+ ),
+ });
+
+ const saveEstimateSubmit = useCallback(
+ (payload) => {
+ onFormSubmit && onFormSubmit(payload);
+ },
+ [onFormSubmit],
+ );
+
+ const defaultEstimate = useMemo(
+ () => ({
+ index: 0,
+ item_id: null,
+ rate: null,
+ discount: 0,
+ quantity: null,
+ description: '',
+ }),
+ [],
+ );
+
+ const defaultInitialValues = useMemo(
+ () => ({
+ customer_id: '',
+ estimate_date: moment(new Date()).format('YYYY-MM-DD'),
+ expiration_date: moment(new Date()).format('YYYY-MM-DD'),
+ estimate_number: '',
+ reference: '',
+ note: '',
+ terms_conditions: '',
+ entries: [...repeatValue(defaultEstimate, MIN_LINES_NUMBER)],
+ }),
+ [defaultEstimate],
+ );
+
+ const orderingProductsIndex = (_entries) => {
+ return _entries.map((item, index) => ({
+ ...item,
+ index: index + 1,
+ }));
+ };
+ // debugger;
+ const initialValues = useMemo(
+ () => ({
+ ...(estimate
+ ? {
+ ...pick(estimate, Object.keys(defaultInitialValues)),
+ entries: [
+ ...estimate.entries.map((estimate) => ({
+ ...pick(estimate, Object.keys(defaultEstimate)),
+ })),
+ ...repeatValue(
+ defaultEstimate,
+ Math.max(MIN_LINES_NUMBER - estimate.entries.length, 0),
+ ),
+ ],
+ }
+ : {
+ ...defaultInitialValues,
+ entries: orderingProductsIndex(defaultInitialValues.entries),
+ }),
+ }),
+ [estimate, defaultInitialValues, defaultEstimate],
+ );
+
+ // const initialValues = useMemo(
+ // () => ({
+ // ...defaultInitialValues,
+ // entries: orderingProductsIndex(defaultInitialValues.entries),
+ // }),
+ // [defaultEstimate, defaultInitialValues, estimate],
+ // );
+
+ const initialAttachmentFiles = useMemo(() => {
+ return estimate && estimate.media
+ ? estimate.media.map((attach) => ({
+ preview: attach.attachment_file,
+ uploaded: true,
+ metadata: { ...attach },
+ }))
+ : [];
+ }, [estimate]);
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ validationSchema,
+ initialValues: {
+ ...initialValues,
+ },
+ onSubmit: async (values, { setSubmitting, setErrors, resetForm }) => {
+ const entries = values.entries.filter(
+ (item) => item.item_id && item.quantity,
+ );
+ const form = {
+ ...values,
+ entries,
+ };
+ const requestForm = { ...form };
+
+ if (estimate && estimate.id) {
+ requestEditEstimate(estimate.id, requestForm).then((response) => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_estimate_has_been_successfully_edited',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ saveEstimateSubmit({ action: 'update', ...payload });
+ resetForm();
+ });
+ } else {
+ requestSubmitEstimate(requestForm)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage(
+ { id: 'the_estimate_has_been_successfully_created' },
+ { number: values.estimate_number },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ resetForm();
+ saveEstimateSubmit({ action: 'new', ...payload });
+ })
+ .catch((errors) => {
+ setSubmitting(false);
+ });
+ }
+ },
+ });
+
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPayload(payload);
+ formik.submitForm();
+ },
+ [setPayload, formik],
+ );
+
+ const handleCancelClick = useCallback(
+ (payload) => {
+ onCancelForm && onCancelForm(payload);
+ },
+ [onCancelForm],
+ );
+
+ const handleDeleteFile = useCallback(
+ (_deletedFiles) => {
+ _deletedFiles.forEach((deletedFile) => {
+ if (deletedFile.uploaded && deletedFile.metadata.id) {
+ setDeletedFiles([...deletedFiles, deletedFile.metadata.id]);
+ }
+ });
+ },
+ [setDeletedFiles, deletedFiles],
+ );
+
+ const handleClickAddNewRow = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingProductsIndex([...formik.values.entries, defaultEstimate]),
+ );
+ };
+
+ const handleClearAllLines = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingProductsIndex([
+ ...repeatValue(defaultEstimate, MIN_LINES_NUMBER),
+ ]),
+ );
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+export default compose(
+ withEstimateActions,
+ withDashboardActions,
+ withMediaActions,
+ withEstimateDetail(),
+)(EstimateForm);
diff --git a/client/src/containers/Sales/Estimate/EstimateFormFooter.js b/client/src/containers/Sales/Estimate/EstimateFormFooter.js
new file mode 100644
index 000000000..cd93c0dd6
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateFormFooter.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { Intent, Button } from '@blueprintjs/core';
+import { FormattedMessage as T } from 'react-intl';
+import { queryCache } from 'react-query';
+
+export default function EstimateFormFooter({
+ formik: { isSubmitting, resetForm },
+ onSubmitClick,
+ onCancelClick,
+ onClearClick,
+ estimate,
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/Sales/Estimate/EstimateFormHeader.js b/client/src/containers/Sales/Estimate/EstimateFormHeader.js
new file mode 100644
index 000000000..ad1212763
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateFormHeader.js
@@ -0,0 +1,192 @@
+import React, { useMemo, useCallback, useState } from 'react';
+import {
+ FormGroup,
+ InputGroup,
+ Intent,
+ Position,
+ MenuItem,
+ Classes,
+} from '@blueprintjs/core';
+import { DateInput } from '@blueprintjs/datetime';
+import { FormattedMessage as T } from 'react-intl';
+import { Row, Col } from 'react-grid-system';
+import moment from 'moment';
+import { momentFormatter, compose, tansformDateValue } from 'utils';
+import classNames from 'classnames';
+import { ListSelect, ErrorMessage, FieldRequiredHint, Hint } from 'components';
+
+import withCustomers from 'containers/Customers/withCustomers';
+
+function EstimateFormHeader({
+ formik: { errors, touched, setFieldValue, getFieldProps, values },
+
+ //#withCustomers
+ customers,
+}) {
+ const handleDateChange = useCallback(
+ (date_filed) => (date) => {
+ const formatted = moment(date).format('YYYY-MM-DD');
+ setFieldValue(date_filed, formatted);
+ },
+ [setFieldValue],
+ );
+
+ const CustomerRenderer = useCallback(
+ (cutomer, { handleClick }) => (
+
+ ),
+ [],
+ );
+
+ // Filter Customer
+ const filterCustomer = (query, customer, _index, exactMatch) => {
+ const normalizedTitle = customer.display_name.toLowerCase();
+ const normalizedQuery = query.toLowerCase();
+ if (exactMatch) {
+ return normalizedTitle === normalizedQuery;
+ } else {
+ return (
+ `${customer.display_name} ${normalizedTitle}`.indexOf(
+ normalizedQuery,
+ ) >= 0
+ );
+ }
+ };
+
+ // handle change customer
+ const onChangeCustomer = useCallback(
+ (filedName) => {
+ return (customer) => {
+ setFieldValue(filedName, customer.id);
+ };
+ },
+ [setFieldValue],
+ );
+
+ return (
+
+
+ {/* customer name */}
+ }
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ labelInfo={}
+ intent={errors.customer_id && touched.customer_id && Intent.DANGER}
+ helperText={
+
+ }
+ >
+ }
+ itemRenderer={CustomerRenderer}
+ itemPredicate={filterCustomer}
+ popoverProps={{ minimal: true }}
+ onItemSelect={onChangeCustomer('customer_id')}
+ selectedItem={values.customer_id}
+ selectedItemProp={'id'}
+ defaultText={}
+ labelProp={'display_name'}
+ />
+
+ {/* estimate_date */}
+
+
+ }
+ inline={true}
+ labelInfo={}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={
+ errors.estimate_date && touched.estimate_date && Intent.DANGER
+ }
+ helperText={
+
+ }
+ >
+
+
+
+
+ }
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={
+ errors.expiration_date &&
+ touched.expiration_date &&
+ Intent.DANGER
+ }
+ helperText={
+
+ }
+ >
+
+
+
+
+
+ {/* Estimate */}
+
}
+ inline={true}
+ className={('form-group--estimate', Classes.FILL)}
+ labelInfo={
}
+ intent={
+ errors.estimate_number && touched.estimate_number && Intent.DANGER
+ }
+ helperText={
+
+ }
+ >
+
+
+
}
+ inline={true}
+ className={classNames('form-group--reference', Classes.FILL)}
+ intent={errors.reference && touched.reference && Intent.DANGER}
+ helperText={
}
+ >
+
+
+
+ );
+}
+
+export default compose(
+ withCustomers(({ customers }) => ({
+ customers,
+ })),
+)(EstimateFormHeader);
diff --git a/client/src/containers/Sales/Estimate/EstimateList.js b/client/src/containers/Sales/Estimate/EstimateList.js
new file mode 100644
index 000000000..56de0e5ca
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateList.js
@@ -0,0 +1,190 @@
+import React, { useEffect, useCallback, useMemo, useState } from 'react';
+import { Route, Switch, useHistory } from 'react-router-dom';
+import { useQuery } from 'react-query';
+import { Alert, Intent } from '@blueprintjs/core';
+
+import AppToaster from 'components/AppToaster';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import DashboardPageContent from 'components/Dashboard/DashboardPageContent';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import EstimatesDataTable from './EstimatesDataTable';
+import EstimateActionsBar from './EstimateActionsBar';
+import EstimateViewTabs from './EstimateViewTabs';
+
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withResourceActions from 'containers/Resources/withResourcesActions';
+import withEstimates from './withEstimates';
+import withEstimateActions from './withEstimateActions';
+import withViewsActions from 'containers/Views/withViewsActions';
+
+import { compose } from 'utils';
+
+function EstimateList({
+ // #withDashboardActions
+ changePageTitle,
+
+ // #withViewsActions
+ requestFetchResourceViews,
+ requestFetchResourceFields,
+
+ // #withEstimate
+ estimateTableQuery,
+ estimateViews,
+
+ //#withEistimateActions
+ requestFetchEstimatesTable,
+ requestDeleteEstimate,
+ addEstimatesTableQueries,
+}) {
+ const history = useHistory();
+ const { formatMessage } = useIntl();
+ const [deleteEstimate, setDeleteEstimate] = useState(false);
+ const [selectedRows, setSelectedRows] = useState([]);
+
+ const fetchResourceViews = useQuery(
+ ['resource-views', 'sales_estimates'],
+ (key, resourceName) => requestFetchResourceViews(resourceName),
+ );
+
+ const fetchResourceFields = useQuery(
+ ['resource-fields', 'sales_estimates'],
+ (key, resourceName) => requestFetchResourceFields(resourceName),
+ );
+
+ const fetchEstimate = useQuery(['estimates-table', estimateTableQuery], () =>
+ requestFetchEstimatesTable(),
+ );
+
+ useEffect(() => {
+ changePageTitle(formatMessage({ id: 'estimate_list' }));
+ }, [changePageTitle, formatMessage]);
+
+ // handle delete estimate click
+ const handleDeleteEstimate = useCallback(
+ (estimate) => {
+ setDeleteEstimate(estimate);
+ },
+ [setDeleteEstimate],
+ );
+
+ // handle cancel estimate
+ const handleCancelEstimateDelete = useCallback(() => {
+ setDeleteEstimate(false);
+ }, [setDeleteEstimate]);
+
+ // handle confirm delete estimate
+ const handleConfirmEstimateDelete = useCallback(() => {
+ requestDeleteEstimate(deleteEstimate.id).then(() => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_estimate_has_been_successfully_deleted',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setDeleteEstimate(false);
+ });
+ }, [deleteEstimate, requestDeleteEstimate, formatMessage]);
+
+ // // Handle filter change to re-fetch data-table.
+ // const handleFilterChanged = useCallback(
+ // (filterConditions) => {
+ // addEstimatesTableQueries({
+ // filter_roles: filterConditions || '',
+ // });
+ // },
+ // [fetchEstimate],
+ // );
+
+ // Handle filter change to re-fetch data-table.
+ const handleFilterChanged = useCallback(() => {}, [fetchEstimate]);
+
+ // Calculates the selected rows
+ const selectedRowsCount = useMemo(() => Object.values(selectedRows).length, [
+ selectedRows,
+ ]);
+
+ const handleEditEstimate = useCallback(
+ (estimate) => {
+ history.push(`/estimates/${estimate.id}/edit`);
+ },
+ [history],
+ );
+ const handleFetchData = useCallback(
+ ({ pageIndex, pageSize, sortBy }) => {
+ const page = pageIndex + 1;
+
+ addEstimatesTableQueries({
+ ...(sortBy.length > 0
+ ? {
+ column_sort_by: sortBy[0].id,
+ sort_order: sortBy[0].desc ? 'desc' : 'asc',
+ }
+ : {}),
+ page_size: pageSize,
+ page,
+ });
+ },
+ [addEstimatesTableQueries],
+ );
+
+ const handleSelectedRowsChange = useCallback(
+ (estimate) => {
+ setSelectedRows(estimate);
+ },
+ [setSelectedRows],
+ );
+ return (
+
+
+
+
+
+
+
+
+
+ }
+ confirmButtonText={}
+ icon={'trash'}
+ intent={Intent.DANGER}
+ isOpen={deleteEstimate}
+ onCancel={handleCancelEstimateDelete}
+ onConfirm={handleConfirmEstimateDelete}
+ >
+
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withResourceActions,
+ withEstimateActions,
+ withDashboardActions,
+ withViewsActions,
+ withEstimates(({ estimateTableQuery, estimateViews }) => ({
+ estimateTableQuery,
+ estimateViews,
+ })),
+)(EstimateList);
diff --git a/client/src/containers/Sales/Estimate/EstimateViewTabs.js b/client/src/containers/Sales/Estimate/EstimateViewTabs.js
new file mode 100644
index 000000000..1583aebd7
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateViewTabs.js
@@ -0,0 +1,110 @@
+import React, { useEffect, useRef } from 'react';
+import { useHistory } from 'react-router';
+import { Alignment, Navbar, NavbarGroup } from '@blueprintjs/core';
+import { useParams, withRouter } from 'react-router-dom';
+import { connect } from 'react-redux';
+import { pick, debounce } from 'lodash';
+
+import { DashboardViewsTabs } from 'components';
+import { useUpdateEffect } from 'hooks';
+
+import withEstimates from './withEstimates';
+import withEstimateActions from './withEstimateActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+
+import { compose } from 'utils';
+
+function EstimateViewTabs({
+ // #withExpenses
+ estimateViews,
+
+ // #withViewDetails
+ viewItem,
+
+ //#withEstimatesActions
+ addEstimatesTableQueries,
+ changeEstimateView,
+
+ // #withDashboardActions
+ setTopbarEditView,
+ changePageSubtitle,
+ // props
+ customViewChanged,
+ onViewChanged,
+}) {
+ const history = useHistory();
+ const { custom_view_id: customViewId = null } = useParams();
+
+ useEffect(() => {
+ changeEstimateView(customViewId || -1);
+ setTopbarEditView(customViewId);
+ changePageSubtitle(customViewId && viewItem ? viewItem.name : '');
+
+ addEstimatesTableQueries({
+ custom_view_id: customViewId,
+ });
+ return () => {
+ setTopbarEditView(null);
+ changePageSubtitle('');
+ changeEstimateView(null);
+ };
+ }, [customViewId, addEstimatesTableQueries, changeEstimateView]);
+
+ useUpdateEffect(() => {
+ onViewChanged && onViewChanged(customViewId);
+ }, [customViewId]);
+
+ const debounceChangeHistory = useRef(
+ debounce((toUrl) => {
+ history.push(toUrl);
+ }, 250),
+ );
+
+ const handleTabsChange = (viewId) => {
+ const toPath = viewId ? `${viewId}/custom_view` : '';
+ debounceChangeHistory.current(`/estimates/${toPath}`);
+ setTopbarEditView(viewId);
+ };
+ const tabs = estimateViews.map((view) => ({
+ ...pick(view, ['name', 'id']),
+ }));
+
+ // Handle click a new view tab.
+ const handleClickNewView = () => {
+ setTopbarEditView(null);
+ history.push('/custom_views/estimates/new');
+ };
+
+ console.log(estimateViews, 'estimateViews');
+ return (
+
+
+
+
+
+ );
+}
+
+const mapStateToProps = (state, ownProps) => ({
+ viewId: ownProps.match.params.custom_view_id,
+});
+
+const withEstimatesViewTabs = connect(mapStateToProps);
+
+export default compose(
+ withRouter,
+ withEstimatesViewTabs,
+ withEstimateActions,
+ withDashboardActions,
+ withViewDetails(),
+ withEstimates(({ estimateViews }) => ({
+ estimateViews,
+ })),
+)(EstimateViewTabs);
diff --git a/client/src/containers/Sales/Estimate/Estimates.js b/client/src/containers/Sales/Estimate/Estimates.js
new file mode 100644
index 000000000..a8f505806
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/Estimates.js
@@ -0,0 +1,68 @@
+import React, { useCallback } from 'react';
+import { useParams, useHistory } from 'react-router-dom';
+import { useQuery } from 'react-query';
+
+import EstimateForm from './EstimateForm';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import withCustomersActions from 'containers/Customers/withCustomersActions';
+import withItemsActions from 'containers/Items/withItemsActions';
+import withEstimateActions from './withEstimateActions';
+
+import { compose } from 'utils';
+
+function Estimates({
+ requestFetchCustomers,
+ requestFetchItems,
+ requsetFetchEstimate,
+}) {
+ const history = useHistory();
+ const { id } = useParams();
+
+ const fetchEstimate = useQuery(
+ ['estimate', id],
+ (key, _id) => requsetFetchEstimate(_id),
+ { enabled: !!id },
+ );
+
+ // Handle fetch Items data table or list
+ const fetchItems = useQuery('items-list', () => requestFetchItems({}));
+
+ // Handle fetch customers data table or list
+ const fetchCustomers = useQuery('customers-table', () =>
+ requestFetchCustomers({}),
+ );
+
+ const handleFormSubmit = useCallback(
+ (payload) => {
+ payload.redirect && history.push('/estimates');
+ },
+ [history],
+ );
+ const handleCancel = useCallback(() => {
+ history.goBack();
+ }, [history]);
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withEstimateActions,
+ withCustomersActions,
+ withItemsActions,
+)(Estimates);
diff --git a/client/src/containers/Sales/Estimate/EstimatesDataTable.js b/client/src/containers/Sales/Estimate/EstimatesDataTable.js
new file mode 100644
index 000000000..75e5d7253
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimatesDataTable.js
@@ -0,0 +1,254 @@
+import React, { useEffect, useCallback, useState, useMemo } from 'react';
+import {
+ Intent,
+ Button,
+ Popover,
+ Menu,
+ MenuItem,
+ MenuDivider,
+ Position,
+} from '@blueprintjs/core';
+import { useParams } from 'react-router-dom';
+import { withRouter } from 'react-router';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import moment from 'moment';
+
+import { compose } from 'utils';
+import { useUpdateEffect } from 'hooks';
+
+import LoadingIndicator from 'components/LoadingIndicator';
+import { DataTable, Money, Icon } from 'components';
+
+import withDialogActions from 'containers/Dialog/withDialogActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+
+import withEstimates from './withEstimates';
+import withEstimateActions from './withEstimateActions';
+import withCurrentView from 'containers/Views/withCurrentView';
+
+function EstimatesDataTable({
+ //#withEitimates
+ estimatesCurrentPage,
+ estimatesLoading,
+ estimatesPageination,
+ estimateItems,
+
+ // #withDashboardActions
+ changeCurrentView,
+ changePageSubtitle,
+ setTopbarEditView,
+
+ // #withView
+ viewMeta,
+
+ //#OwnProps
+ loading,
+ onFetchData,
+ onEditEstimate,
+ onDeleteEstimate,
+ onSelectedRowsChange,
+}) {
+ const [initialMount, setInitialMount] = useState(false);
+ const { custom_view_id: customViewId } = useParams();
+ const { formatMessage } = useIntl();
+
+ useEffect(() => {
+ setInitialMount(false);
+ }, []);
+
+ useUpdateEffect(() => {
+ if (!estimatesLoading) {
+ setInitialMount(true);
+ }
+ }, [estimatesLoading, setInitialMount]);
+
+ useEffect(() => {
+ if (customViewId) {
+ changeCurrentView(customViewId);
+ setTopbarEditView(customViewId);
+ }
+ changePageSubtitle(customViewId && viewMeta ? viewMeta.name : '');
+ }, [
+ customViewId,
+ changeCurrentView,
+ changePageSubtitle,
+ setTopbarEditView,
+ viewMeta,
+ ]);
+
+ const handleEditEstimate = useCallback(
+ (estimate) => () => {
+ onEditEstimate && onEditEstimate(estimate);
+ },
+ [onEditEstimate],
+ );
+
+ const handleDeleteEstimate = useCallback(
+ (estimate) => () => {
+ onDeleteEstimate && onDeleteEstimate(estimate);
+ },
+ [onDeleteEstimate],
+ );
+
+ const actionMenuList = useCallback(
+ (estimate) => (
+
+ ),
+ [handleDeleteEstimate, handleEditEstimate, formatMessage],
+ );
+
+ const onRowContextMenu = useCallback(
+ (cell) => {
+ return actionMenuList(cell.row.original);
+ },
+ [actionMenuList],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'estimate_date',
+ Header: formatMessage({ id: 'estimate_date' }),
+ accessor: (r) => moment(r.estimate_date).format('YYYY MMM DD'),
+ width: 140,
+ className: 'estimate_date',
+ },
+ {
+ id: 'customer_id',
+ Header: formatMessage({ id: 'customer_name' }),
+ accessor: 'customer.display_name',
+ width: 140,
+ className: 'customer_id',
+ },
+ {
+ id: 'expiration_date',
+ Header: formatMessage({ id: 'expiration_date' }),
+ accessor: (r) => moment(r.expiration_date).format('YYYY MMM DD'),
+ width: 140,
+ className: 'expiration_date',
+ },
+ {
+ id: 'estimate_number',
+ Header: formatMessage({ id: 'estimate_number' }),
+ accessor: (row) => `#${row.estimate_number}`,
+ width: 140,
+ className: 'estimate_number',
+ },
+ {
+ id: 'amount',
+ Header: formatMessage({ id: 'amount' }),
+ accessor: (r) => ,
+
+ width: 140,
+ className: 'amount',
+ },
+ {
+ id: 'reference',
+ Header: formatMessage({ id: 'reference_no' }),
+ accessor: 'reference',
+ width: 140,
+ className: 'reference',
+ },
+ {
+ id: 'actions',
+ Header: '',
+ Cell: ({ cell }) => (
+
+ } />
+
+ ),
+ className: 'actions',
+ width: 50,
+ disableResizing: true,
+ },
+ ],
+ [actionMenuList, formatMessage],
+ );
+ const selectionColumn = useMemo(
+ () => ({
+ minWidth: 40,
+ width: 40,
+ maxWidth: 40,
+ }),
+ [],
+ );
+
+ const handleDataTableFetchData = useCallback(
+ (...args) => {
+ onFetchData && onFetchData(...args);
+ },
+ [onFetchData],
+ );
+
+ const handleSelectedRowsChange = useCallback(
+ (selectedRows) => {
+ onSelectedRowsChange &&
+ onSelectedRowsChange(selectedRows.map((s) => s.original));
+ },
+ [onSelectedRowsChange],
+ );
+ console.log(estimatesCurrentPage, 'estimatesCurrentPage');
+ console.log(estimateItems, 'estimateItems');
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withRouter,
+ withCurrentView,
+ withDialogActions,
+ withDashboardActions,
+ withEstimateActions,
+ withEstimates(
+ ({
+ estimatesCurrentPage,
+ estimatesLoading,
+ estimatesPageination,
+ estimateItems,
+ }) => ({
+ estimatesCurrentPage,
+ estimatesLoading,
+ estimatesPageination,
+ estimateItems,
+ }),
+ ),
+ withViewDetails(),
+)(EstimatesDataTable);
diff --git a/client/src/containers/Sales/Estimate/withEstimateActions.js b/client/src/containers/Sales/Estimate/withEstimateActions.js
new file mode 100644
index 000000000..4f9e43ce5
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/withEstimateActions.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import {
+ submitEstimate,
+ editEstimate,
+ deleteEstimate,
+ fetchEstimate,
+ fetchEstimatesTable,
+} from 'store/Estimate/estimates.actions';
+import t from 'store/types';
+
+const mapDipatchToProps = (dispatch) => ({
+ requestSubmitEstimate: (form) => dispatch(submitEstimate({ form })),
+ requsetFetchEstimate: (id) => dispatch(fetchEstimate({ id })),
+ requestEditEstimate: (id, form) => dispatch(editEstimate(id, form)),
+ requestFetchEstimatesTable: (query = {}) =>
+ dispatch(fetchEstimatesTable({ query: { ...query } })),
+ requestDeleteEstimate: (id) => dispatch(deleteEstimate({ id })),
+
+ changeEstimateView: (id) =>
+ dispatch({
+ type: t.ESTIMATES_SET_CURRENT_VIEW,
+ currentViewId: parseInt(id, 10),
+ }),
+
+ addEstimatesTableQueries: (queries) =>
+ dispatch({
+ type: t.ESTIMATES_TABLE_QUERIES_ADD,
+ queries,
+ }),
+});
+
+export default connect(null, mapDipatchToProps);
diff --git a/client/src/containers/Sales/Estimate/withEstimateDetail.js b/client/src/containers/Sales/Estimate/withEstimateDetail.js
new file mode 100644
index 000000000..3d5dfbdf7
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/withEstimateDetail.js
@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import { getEstimateByIdFactory } from 'store/Estimate/estimates.selectors';
+
+export default () => {
+ const getEstimateById = getEstimateByIdFactory();
+
+ const mapStateToProps = (state, props) => ({
+ estimate: getEstimateById(state, props),
+ });
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Sales/Estimate/withEstimates.js b/client/src/containers/Sales/Estimate/withEstimates.js
new file mode 100644
index 000000000..731f65f5c
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/withEstimates.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import { getResourceViews } from 'store/customViews/customViews.selectors';
+import {
+ getEstimateCurrentPageFactory,
+ getEstimatesTableQueryFactory,
+ getEstimatesPaginationMetaFactory,
+} from 'store/Estimate/estimates.selectors';
+
+export default (mapState) => {
+ const getEstimatesItems = getEstimateCurrentPageFactory();
+ const getEstimatesPaginationMeta = getEstimatesPaginationMetaFactory();
+ const getEstimatesTableQuery = getEstimatesTableQueryFactory();
+
+ const mapStateToProps = (state, props) => {
+ const query = getEstimatesTableQuery(state, props);
+ const mapped = {
+ estimatesCurrentPage: getEstimatesItems(state, props, query),
+ estimateViews: getResourceViews(state, props, 'sales_estimates'),
+ estimateItems: state.sales_estimates.items,
+ estimateTableQuery: query,
+ estimatesPageination: getEstimatesPaginationMeta(state, props, query),
+ estimatesLoading: state.sales_estimates.loading,
+ };
+ return mapState ? mapState(mapped, state, props) : mapped;
+ };
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Sales/Invoice/InvoiceActionsBar.js b/client/src/containers/Sales/Invoice/InvoiceActionsBar.js
new file mode 100644
index 000000000..a3d6dde72
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceActionsBar.js
@@ -0,0 +1,151 @@
+import React, { useCallback, useState, useMemo } from 'react';
+import Icon from 'components/Icon';
+import {
+ Button,
+ Classes,
+ Menu,
+ MenuItem,
+ Popover,
+ NavbarDivider,
+ NavbarGroup,
+ PopoverInteractionKind,
+ Position,
+ Intent,
+} from '@blueprintjs/core';
+
+import classNames from 'classnames';
+import { useRouteMatch, useHistory } from 'react-router-dom';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+
+import { connect } from 'react-redux';
+import FilterDropdown from 'components/FilterDropdown';
+import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar';
+
+import { If, DashboardActionViewsList } from 'components';
+
+import withResourceDetail from 'containers/Resources/withResourceDetails';
+import withDialogActions from 'containers/Dialog/withDialogActions';
+
+import withInvoiceActions from './withInvoiceActions';
+import withInvoices from './withInvoices';
+
+import { compose } from 'utils';
+
+function InvoiceActionsBar({
+ // #withResourceDetail
+ resourceFields,
+
+ //#withInvoice
+ invoicesViews,
+
+ // #withInvoiceActions
+ addInvoiceTableQueries,
+
+ // #own Porps
+ onFilterChanged,
+ selectedRows = [],
+}) {
+ const history = useHistory();
+ const { path } = useRouteMatch();
+ const [filterCount, setFilterCount] = useState(0);
+ const { formatMessage } = useIntl();
+
+ const handleClickNewInvoice = useCallback(() => {
+ history.push('/invoices/new');
+ }, [history]);
+
+ // const filterDropdown = FilterDropdown({
+ // initialCondition: {
+ // fieldKey: 'reference_no',
+ // compatator: 'contains',
+ // value: '',
+ // },
+ // fields: resourceFields,
+ // onFilterChange: (filterConditions) => {
+ // addInvoiceTableQueries({
+ // filter_roles: filterConditions || '',
+ // });
+ // onFilterChanged && onFilterChanged(filterConditions);
+ // },
+ // });
+
+ const hasSelectedRows = useMemo(() => selectedRows.length > 0, [
+ selectedRows,
+ ]);
+
+ return (
+
+
+
+
+ }
+ text={}
+ onClick={handleClickNewInvoice}
+ />
+
+
+ ) : (
+ `${filterCount} ${formatMessage({ id: 'filters_applied' })}`
+ )
+ }
+ icon={}
+ />
+
+
+ }
+ text={}
+ intent={Intent.DANGER}
+ // onClick={handleBulkDelete}
+ />
+
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+
+
+ );
+}
+
+const mapStateToProps = (state, props) => ({
+ resourceName: 'sales_invoices',
+});
+const withInvoiceActionsBar = connect(mapStateToProps);
+
+export default compose(
+ withInvoiceActionsBar,
+ withResourceDetail(({ resourceFields }) => ({
+ resourceFields,
+ })),
+ withInvoices(({ invoicesViews }) => ({
+ invoicesViews,
+ })),
+ withInvoiceActions,
+)(InvoiceActionsBar);
diff --git a/client/src/containers/Sales/Invoice/InvoiceForm.js b/client/src/containers/Sales/Invoice/InvoiceForm.js
new file mode 100644
index 000000000..8b6c44e07
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceForm.js
@@ -0,0 +1,351 @@
+import React, {
+ useMemo,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import * as Yup from 'yup';
+import { useFormik } from 'formik';
+import moment from 'moment';
+import { Intent, FormGroup, TextArea, Button } from '@blueprintjs/core';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import { pick } from 'lodash';
+
+import InvoiceFormHeader from './InvoiceFormHeader';
+import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable';
+import InvoiceFormFooter from './InvoiceFormFooter';
+
+import withInvoiceActions from './withInvoiceActions';
+import withInvoiceDetail from './withInvoiceDetail';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withMediaActions from 'containers/Media/withMediaActions';
+
+import { AppToaster } from 'components';
+import Dragzone from 'components/Dragzone';
+import useMedia from 'hooks/useMedia';
+
+import { compose, repeatValue } from 'utils';
+
+const MIN_LINES_NUMBER = 4;
+
+function InvoiceForm({
+ //#WithMedia
+ requestSubmitMedia,
+ requestDeleteMedia,
+
+ //#WithInvoiceActions
+ requestSubmitInvoice,
+ requestEditInvoice,
+
+ //#withDashboard
+ changePageTitle,
+ changePageSubtitle,
+
+ //#withInvoiceDetail
+ invoice,
+
+ //#own Props
+ invoiceId,
+ onFormSubmit,
+ onCancelForm,
+}) {
+ const { formatMessage } = useIntl();
+ const [payload, setPayload] = useState({});
+
+ const {
+ setFiles,
+ saveMedia,
+ deletedFiles,
+ setDeletedFiles,
+ deleteMedia,
+ } = useMedia({
+ saveCallback: requestSubmitMedia,
+ deleteCallback: requestDeleteMedia,
+ });
+
+ const handleDropFiles = useCallback((_files) => {
+ setFiles(_files.filter((file) => file.uploaded === false));
+ }, []);
+
+ const savedMediaIds = useRef([]);
+ const clearSavedMediaIds = () => {
+ savedMediaIds.current = [];
+ };
+ useEffect(() => {
+ if (invoice && invoice.id) {
+ changePageTitle(formatMessage({ id: 'edit_invoice' }));
+ } else {
+ changePageTitle(formatMessage({ id: 'new_invoice' }));
+ }
+ }, [changePageTitle, invoice, formatMessage]);
+
+ const validationSchema = Yup.object().shape({
+ customer_id: Yup.string()
+ .label(formatMessage({ id: 'customer_name_' }))
+ .required(),
+ invoice_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'invoice_date_' })),
+ due_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'due_date_' })),
+ invoice_no: Yup.number()
+ .required()
+ .label(formatMessage({ id: 'invoice_no_' })),
+ reference_no: Yup.string().min(1).max(255),
+ status: Yup.string().required(),
+ invoice_message: Yup.string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .label(formatMessage({ id: 'note' })),
+ terms_conditions: Yup.string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .label(formatMessage({ id: 'note' })),
+
+ entries: Yup.array().of(
+ Yup.object().shape({
+ quantity: Yup.number().nullable(),
+ rate: Yup.number().nullable(),
+ item_id: Yup.number()
+ .nullable()
+ .when(['quantity', 'rate'], {
+ is: (quantity, rate) => quantity || rate,
+ then: Yup.number().required(),
+ }),
+ discount: Yup.number().nullable(),
+ description: Yup.string().nullable(),
+ }),
+ ),
+ });
+
+ const saveInvokeSubmit = useCallback(
+ (payload) => {
+ onFormSubmit && onFormSubmit(payload);
+ },
+ [onFormSubmit],
+ );
+
+ const defaultInvoice = useMemo(
+ () => ({
+ index: 0,
+ item_id: null,
+ rate: null,
+ discount: 0,
+ quantity: null,
+ description: '',
+ }),
+ [],
+ );
+
+ const defaultInitialValues = useMemo(
+ () => ({
+ customer_id: '',
+ invoice_date: moment(new Date()).format('YYYY-MM-DD'),
+ due_date: moment(new Date()).format('YYYY-MM-DD'),
+ status: 'SEND',
+ invoice_no: '',
+ reference_no: '',
+ invoice_message: '',
+ terms_conditions: '',
+ entries: [...repeatValue(defaultInvoice, MIN_LINES_NUMBER)],
+ }),
+ [defaultInvoice],
+ );
+
+ const orderingIndex = (_invoice) => {
+ return _invoice.map((item, index) => ({
+ ...item,
+ index: index + 1,
+ }));
+ };
+ // debugger;
+ const initialValues = useMemo(
+ () => ({
+ ...(invoice
+ ? {
+ ...pick(invoice, Object.keys(defaultInitialValues)),
+ entries: [
+ ...invoice.entries.map((invoice) => ({
+ ...pick(invoice, Object.keys(defaultInvoice)),
+ })),
+ ...repeatValue(
+ defaultInvoice,
+ Math.max(MIN_LINES_NUMBER - invoice.entries.length, 0),
+ ),
+ ],
+ }
+ : {
+ ...defaultInitialValues,
+ entries: orderingIndex(defaultInitialValues.entries),
+ }),
+ }),
+ [invoice, defaultInitialValues, defaultInvoice],
+ );
+
+ // const initialValues = useMemo(
+ // () => ({
+ // ...defaultInitialValues,
+ // entries: orderingIndex(defaultInitialValues.entries),
+ // }),
+ // [defaultInvoice, defaultInitialValues, invoice],
+ // );
+
+ const initialAttachmentFiles = useMemo(() => {
+ return invoice && invoice.media
+ ? invoice.media.map((attach) => ({
+ preview: attach.attachment_file,
+ uploaded: true,
+ metadata: { ...attach },
+ }))
+ : [];
+ }, [invoice]);
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ validationSchema,
+ initialValues: {
+ ...initialValues,
+ },
+ onSubmit: async (values, { setSubmitting, setErrors, resetForm }) => {
+ setSubmitting(true);
+
+ const entries = values.entries.filter(
+ (item) => item.item_id && item.quantity,
+ );
+ const form = {
+ ...values,
+ entries,
+ };
+
+ const requestForm = { ...form };
+ if (invoice && invoice.id) {
+ requestEditInvoice(invoice.id, requestForm)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_invoice_has_been_successfully_edited',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ saveInvokeSubmit({ action: 'update', ...payload });
+ resetForm();
+ })
+ .catch((error) => {
+ setSubmitting(false);
+ });
+ } else {
+ requestSubmitInvoice(requestForm)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage(
+ { id: 'the_invocie_has_been_successfully_created' },
+ { number: values.invoice_no },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ saveInvokeSubmit({ action: 'new', ...payload });
+ resetForm();
+ })
+ .catch((errors) => {
+ setSubmitting(false);
+ });
+ }
+ },
+ });
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPayload(payload);
+ formik.submitForm();
+ },
+ [setPayload, formik],
+ );
+
+ const handleCancelClick = useCallback(
+ (payload) => {
+ onCancelForm && onCancelForm(payload);
+ },
+ [onCancelForm],
+ );
+
+ const handleDeleteFile = useCallback(
+ (_deletedFiles) => {
+ _deletedFiles.forEach((deletedFile) => {
+ if (deletedFile.uploaded && deletedFile.metadata.id) {
+ setDeletedFiles([...deletedFiles, deletedFile.metadata.id]);
+ }
+ });
+ },
+ [setDeletedFiles, deletedFiles],
+ );
+
+ const handleClickAddNewRow = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingIndex([...formik.values.entries, defaultInvoice]),
+ );
+ };
+
+ const handleClearAllLines = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingIndex([...repeatValue(defaultInvoice, MIN_LINES_NUMBER)]),
+ );
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+export default compose(
+ withInvoiceActions,
+ withDashboardActions,
+ withMediaActions,
+ withInvoiceDetail(),
+)(InvoiceForm);
diff --git a/client/src/containers/Sales/Invoice/InvoiceFormFooter.js b/client/src/containers/Sales/Invoice/InvoiceFormFooter.js
new file mode 100644
index 000000000..466dcc665
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceFormFooter.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import { Intent, Button } from '@blueprintjs/core';
+import { FormattedMessage as T } from 'react-intl';
+
+export default function EstimateFormFooter({
+ formik: { isSubmitting },
+ onSubmitClick,
+ onCancelClick,
+ invoice,
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/Sales/Invoice/InvoiceFormHeader.js b/client/src/containers/Sales/Invoice/InvoiceFormHeader.js
new file mode 100644
index 000000000..e9c95b768
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceFormHeader.js
@@ -0,0 +1,177 @@
+import React, { useMemo, useCallback, useState } from 'react';
+import {
+ FormGroup,
+ InputGroup,
+ Intent,
+ Position,
+ MenuItem,
+ Classes,
+} from '@blueprintjs/core';
+import { DateInput } from '@blueprintjs/datetime';
+import { FormattedMessage as T } from 'react-intl';
+import { Row, Col } from 'react-grid-system';
+import moment from 'moment';
+import { momentFormatter, compose, tansformDateValue } from 'utils';
+import classNames from 'classnames';
+import { ListSelect, ErrorMessage, FieldRequiredHint, Hint } from 'components';
+
+import withCustomers from 'containers/Customers/withCustomers';
+
+function InvoiceFormHeader({
+ formik: { errors, touched, setFieldValue, getFieldProps, values },
+
+ //#withCustomers
+ customers,
+}) {
+ const handleDateChange = useCallback(
+ (date_filed) => (date) => {
+ const formatted = moment(date).format('YYYY-MM-DD');
+ setFieldValue(date_filed, formatted);
+ },
+ [setFieldValue],
+ );
+
+ const CustomerRenderer = useCallback(
+ (cutomer, { handleClick }) => (
+
+ ),
+ [],
+ );
+
+ // Filter Customer
+ const filterCustomer = (query, customer, _index, exactMatch) => {
+ const normalizedTitle = customer.display_name.toLowerCase();
+ const normalizedQuery = query.toLowerCase();
+ if (exactMatch) {
+ return normalizedTitle === normalizedQuery;
+ } else {
+ return (
+ `${customer.display_name} ${normalizedTitle}`.indexOf(
+ normalizedQuery,
+ ) >= 0
+ );
+ }
+ };
+
+ // handle change customer
+ const onChangeCustomer = useCallback(
+ (filedName) => {
+ return (customer) => {
+ setFieldValue(filedName, customer.id);
+ };
+ },
+ [setFieldValue],
+ );
+
+ return (
+
+
+ {/* customer name */}
+ }
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ labelInfo={}
+ intent={errors.customer_id && touched.customer_id && Intent.DANGER}
+ helperText={
+
+ }
+ >
+ }
+ itemRenderer={CustomerRenderer}
+ itemPredicate={filterCustomer}
+ popoverProps={{ minimal: true }}
+ onItemSelect={onChangeCustomer('customer_id')}
+ selectedItem={values.customer_id}
+ selectedItemProp={'id'}
+ defaultText={}
+ labelProp={'display_name'}
+ />
+
+
+
+ }
+ inline={true}
+ labelInfo={}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={
+ errors.invoice_date && touched.invoice_date && Intent.DANGER
+ }
+ helperText={
+
+ }
+ >
+
+
+
+
+ }
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={errors.due_date && touched.due_date && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+
+ {/* invoice */}
+ }
+ inline={true}
+ className={('form-group--estimate', Classes.FILL)}
+ labelInfo={}
+ intent={errors.invoice_no && touched.invoice_no && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+
}
+ inline={true}
+ className={classNames('form-group--reference', Classes.FILL)}
+ intent={errors.reference_no && touched.reference_no && Intent.DANGER}
+ helperText={
}
+ >
+
+
+
+ );
+}
+
+export default compose(
+ withCustomers(({ customers }) => ({
+ customers,
+ })),
+)(InvoiceFormHeader);
diff --git a/client/src/containers/Sales/Invoice/InvoiceList.js b/client/src/containers/Sales/Invoice/InvoiceList.js
new file mode 100644
index 000000000..12ba1b74d
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceList.js
@@ -0,0 +1,177 @@
+import React, { useEffect, useCallback, useMemo, useState } from 'react';
+import { Route, Switch, useHistory } from 'react-router-dom';
+import { useQuery, queryCache } from 'react-query';
+import { Alert, Intent } from '@blueprintjs/core';
+
+import AppToaster from 'components/AppToaster';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import DashboardPageContent from 'components/Dashboard/DashboardPageContent';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import InvoicesDataTable from './InvoicesDataTable';
+import InvoiceActionsBar from './InvoiceActionsBar';
+import InvoiceViewTabs from './InvoiceViewTabs';
+
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withResourceActions from 'containers/Resources/withResourcesActions';
+import withInvoices from './withInvoices';
+import withInvoiceActions from './withInvoiceActions';
+import withViewsActions from 'containers/Views/withViewsActions';
+
+import { compose } from 'utils';
+
+function InvoiceList({
+ // #withDashboardActions
+ changePageTitle,
+
+ // #withViewsActions
+ requestFetchResourceViews,
+ requestFetchResourceFields,
+
+ //#withInvoice
+ invoicesTableQuery,
+ invoicesViews,
+
+ //#withInvoiceActions
+ requestFetchInvoiceTable,
+ requestDeleteInvoice,
+ addInvoiceTableQueries,
+}) {
+ const history = useHistory();
+ const { formatMessage } = useIntl();
+ const [deleteInvoice, setDeleteInvoice] = useState(false);
+ const [selectedRows, setSelectedRows] = useState([]);
+
+ useEffect(() => {
+ changePageTitle(formatMessage({ id: 'invoice_list' }));
+ }, [changePageTitle, formatMessage]);
+
+ const fetchResourceViews = useQuery(
+ ['resource-views', 'sales_invoices'],
+ (key, resourceName) => requestFetchResourceViews(resourceName),
+ );
+
+ const fetchResourceFields = useQuery(
+ ['resource-fields', 'sales_invoices'],
+ (key, resourceName) => requestFetchResourceFields(resourceName),
+ );
+
+ const fetchInvoices = useQuery(['invoices-table', invoicesTableQuery], () =>
+ requestFetchInvoiceTable(),
+ );
+ //handle dalete Invoice
+ const handleDeleteInvoice = useCallback(
+ (invoice) => {
+ setDeleteInvoice(invoice);
+ },
+ [setDeleteInvoice],
+ );
+
+ // handle cancel Invoice
+ const handleCancelInvoiceDelete = useCallback(() => {
+ setDeleteInvoice(false);
+ }, [setDeleteInvoice]);
+
+ // handleConfirm delete invoice
+ const handleConfirmInvoiceDelete = useCallback(() => {
+ requestDeleteInvoice(deleteInvoice.id).then(() => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_invocie_has_been_successfully_deleted',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setDeleteInvoice(false);
+ });
+ }, [deleteInvoice, requestDeleteInvoice, formatMessage]);
+
+ const handleEditInvoice = useCallback((invoice) => {
+ history.push(`/invoices/${invoice.id}/edit`);
+ });
+
+ // Calculates the selected rows count.
+ const selectedRowsCount = useMemo(() => Object.values(selectedRows).length, [
+ selectedRows,
+ ]);
+
+ const handleFetchData = useCallback(
+ ({ pageIndex, pageSize, sortBy }) => {
+ const page = pageIndex + 1;
+
+ addInvoiceTableQueries({
+ ...(sortBy.length > 0
+ ? {
+ column_sort_by: sortBy[0].id,
+ sort_order: sortBy[0].desc ? 'desc' : 'asc',
+ }
+ : {}),
+ page_size: pageSize,
+ page,
+ });
+ },
+ [addInvoiceTableQueries],
+ );
+
+ // Handle filter change to re-fetch data-table.
+ const handleFilterChanged = useCallback(() => {}, [fetchInvoices]);
+
+ // Handle selected rows change.
+ const handleSelectedRowsChange = useCallback(
+ (_invoices) => {
+ setSelectedRows(_invoices);
+ },
+ [setSelectedRows],
+ );
+ return (
+
+
+
+
+
+
+
+
+
+ }
+ confirmButtonText={}
+ icon={'trash'}
+ intent={Intent.DANGER}
+ isOpen={deleteInvoice}
+ onCancel={handleCancelInvoiceDelete}
+ onConfirm={handleConfirmInvoiceDelete}
+ >
+
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withResourceActions,
+ withInvoiceActions,
+ withDashboardActions,
+ withViewsActions,
+ withInvoices(({ invoicesTableQuery }) => ({
+ invoicesTableQuery,
+ })),
+)(InvoiceList);
diff --git a/client/src/containers/Sales/Invoice/InvoiceViewTabs.js b/client/src/containers/Sales/Invoice/InvoiceViewTabs.js
new file mode 100644
index 000000000..fcb9ace55
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceViewTabs.js
@@ -0,0 +1,113 @@
+import React, { useEffect, useRef } from 'react';
+import { useHistory } from 'react-router';
+import { Alignment, Navbar, NavbarGroup } from '@blueprintjs/core';
+import { useParams, withRouter } from 'react-router-dom';
+import { connect } from 'react-redux';
+import { pick, debounce } from 'lodash';
+
+import { DashboardViewsTabs } from 'components';
+import { useUpdateEffect } from 'hooks';
+
+import withInvoices from './withInvoices';
+import withInvoiceActions from './withInvoiceActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+
+import { compose } from 'utils';
+
+function InvoiceViewTabs({
+ //#withInvoices
+ invoicesViews,
+
+ // #withViewDetails
+ viewItem,
+
+ //#withInvoiceActions
+ changeInvoiceView,
+ addInvoiceTableQueries,
+
+ // #withDashboardActions
+ setTopbarEditView,
+ changePageSubtitle,
+
+ // #ownProps
+ customViewChanged,
+ onViewChanged,
+}) {
+ const history = useHistory();
+ const { custom_view_id: customViewId = null } = useParams();
+
+ useEffect(() => {
+ changeInvoiceView(customViewId || -1);
+ setTopbarEditView(customViewId);
+ changePageSubtitle(customViewId && viewItem ? viewItem.name : '');
+
+ addInvoiceTableQueries({
+ custom_view_id: customViewId,
+ });
+ return () => {
+ setTopbarEditView(null);
+ changePageSubtitle('');
+ changeInvoiceView(null);
+ };
+ }, [customViewId, addInvoiceTableQueries, changeInvoiceView]);
+
+ useUpdateEffect(() => {
+ onViewChanged && onViewChanged(customViewId);
+ }, [customViewId]);
+
+
+
+ const debounceChangeHistory = useRef(
+ debounce((toUrl) => {
+ history.push(toUrl);
+ }, 250),
+ );
+
+ const handleTabsChange = (viewId) => {
+ const toPath = viewId ? `${viewId}/custom_view` : '';
+ debounceChangeHistory.current(`/invoices/${toPath}`);
+ setTopbarEditView(viewId);
+ };
+ const tabs = invoicesViews.map((view) => ({
+ ...pick(view, ['name', 'id']),
+ }));
+
+ // Handle click a new view tab.
+ const handleClickNewView = () => {
+ setTopbarEditView(null);
+ history.push('/custom_views/invoices/new');
+ };
+ console.log(invoicesViews, 'invoicesViews');
+
+ return (
+
+
+
+
+
+ );
+}
+
+const mapStateToProps = (state, ownProps) => ({
+ viewId: ownProps.match.params.custom_view_id,
+});
+
+const withInvoicesViewTabs = connect(mapStateToProps);
+
+export default compose(
+ withRouter,
+ withInvoicesViewTabs,
+ withInvoiceActions,
+ withDashboardActions,
+ withViewDetails(),
+ withInvoices(({ invoicesViews }) => ({
+ invoicesViews,
+ })),
+)(InvoiceViewTabs);
diff --git a/client/src/containers/Sales/Invoice/Invoices.js b/client/src/containers/Sales/Invoice/Invoices.js
new file mode 100644
index 000000000..d41a83fe5
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/Invoices.js
@@ -0,0 +1,68 @@
+import React, { useCallback } from 'react';
+import { useParams, useHistory } from 'react-router-dom';
+import { useQuery } from 'react-query';
+
+import InvoiceForm from './InvoiceForm';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import withCustomersActions from 'containers/Customers/withCustomersActions';
+import withItemsActions from 'containers/Items/withItemsActions';
+import withInvoiceActions from './withInvoiceActions';
+
+import { compose } from 'utils';
+
+function Invoices({
+ requestFetchCustomers,
+ requestFetchItems,
+ requsetFetchInvoice,
+}) {
+ const history = useHistory();
+ const { id } = useParams();
+
+ // Handle fetch Items data table or list
+ const fetchItems = useQuery('items-table', () => requestFetchItems({}));
+
+ const handleFormSubmit = useCallback(
+ (payload) => {
+ payload.redirect && history.push('/invoices');
+ },
+ [history],
+ );
+ // Handle fetch customers data table or list
+ const fetchCustomers = useQuery('customers-table', () =>
+ requestFetchCustomers({}),
+ );
+
+ const fetchInvoice = useQuery(
+ ['invoice', id],
+ (key, _id) => requsetFetchInvoice(_id),
+ { enabled: !!id },
+ );
+
+ const handleCancel = useCallback(() => {
+ history.goBack();
+ }, [history]);
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withInvoiceActions,
+ withCustomersActions,
+ withItemsActions,
+)(Invoices);
diff --git a/client/src/containers/Sales/Invoice/InvoicesDataTable.js b/client/src/containers/Sales/Invoice/InvoicesDataTable.js
new file mode 100644
index 000000000..b4ab50cae
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoicesDataTable.js
@@ -0,0 +1,254 @@
+import React, { useEffect, useCallback, useState, useMemo } from 'react';
+import {
+ Intent,
+ Button,
+ Classes,
+ Popover,
+ Menu,
+ MenuItem,
+ MenuDivider,
+ Position,
+} from '@blueprintjs/core';
+import { useParams } from 'react-router-dom';
+import { withRouter } from 'react-router';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import moment from 'moment';
+
+import { compose } from 'utils';
+import { useUpdateEffect } from 'hooks';
+
+import LoadingIndicator from 'components/LoadingIndicator';
+import { DataTable, Money, Icon } from 'components';
+
+import withDialogActions from 'containers/Dialog/withDialogActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+
+import withInvoices from './withInvoices';
+import withInvoiceActions from './withInvoiceActions';
+import withCurrentView from 'containers/Views/withCurrentView';
+
+function InvoicesDataTable({
+ //#withInvoices
+ invoicesCurrentPage,
+ invoicesLoading,
+ invoicesPageination,
+ invoicesItems,
+
+ // #withDashboardActions
+ changeCurrentView,
+ changePageSubtitle,
+ setTopbarEditView,
+
+ // #withView
+ viewMeta,
+
+ //#OwnProps
+ loading,
+ onFetchData,
+ onEditInvoice,
+ onDeleteInvoice,
+ onSelectedRowsChange,
+}) {
+ const [initialMount, setInitialMount] = useState(false);
+ const { custom_view_id: customViewId } = useParams();
+ const { formatMessage } = useIntl();
+
+ useEffect(() => {
+ setInitialMount(false);
+ }, [customViewId]);
+
+ useUpdateEffect(() => {
+ if (!invoicesLoading) {
+ setInitialMount(true);
+ }
+ }, [invoicesLoading, setInitialMount]);
+
+ // useEffect(() => {
+ // if (customViewId) {
+ // changeCurrentView(customViewId);
+ // setTopbarEditView(customViewId);
+ // }
+ // changePageSubtitle(customViewId && viewMeta ? viewMeta.name : '');
+ // }, [
+ // customViewId,
+ // changeCurrentView,
+ // changePageSubtitle,
+ // setTopbarEditView,
+ // viewMeta,
+ // ]);
+
+ const handleEditInvoice = useCallback(
+ (_invoice) => () => {
+ onEditInvoice && onEditInvoice(_invoice);
+ },
+ [onEditInvoice],
+ );
+
+ const handleDeleteInvoice = useCallback(
+ (_invoice) => () => {
+ onDeleteInvoice && onDeleteInvoice(_invoice);
+ },
+ [onDeleteInvoice],
+ );
+
+ const actionMenuList = useCallback(
+ (invoice) => (
+
+ ),
+ [handleDeleteInvoice, handleEditInvoice, formatMessage],
+ );
+
+ const onRowContextMenu = useCallback(
+ (cell) => {
+ return actionMenuList(cell.row.original);
+ },
+ [actionMenuList],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'invoice_date',
+ Header: formatMessage({ id: 'invoice_date' }),
+ accessor: (r) => moment(r.invoice_date).format('YYYY MMM DD'),
+ width: 140,
+ className: 'invoice_date',
+ },
+ {
+ id: 'customer_id',
+ Header: formatMessage({ id: 'customer_name' }),
+ accessor: 'customer.display_name',
+ width: 140,
+ className: 'customer_id',
+ },
+ {
+ id: 'invoice_no',
+ Header: formatMessage({ id: 'invoice_no__' }),
+ accessor: (row) => `#${row.invoice_no}`,
+ width: 140,
+ className: 'invoice_no',
+ },
+
+ {
+ id: 'due_date',
+ Header: formatMessage({ id: 'due_date' }),
+ accessor: (r) => moment(r.due_date).format('YYYY MMM DD'),
+ width: 140,
+ className: 'due_date',
+ },
+ {
+ id: 'balance',
+ Header: formatMessage({ id: 'balance' }),
+ accessor: (r) => ,
+ width: 140,
+ className: 'balance',
+ },
+ {
+ id: 'reference_no',
+ Header: formatMessage({ id: 'reference_no' }),
+ accessor: 'reference_no',
+ width: 140,
+ className: 'reference_no',
+ },
+ {
+ id: 'status',
+ Header: formatMessage({ id: 'status' }),
+ accessor: 'status',
+ width: 140,
+ className: 'status',
+ },
+ {
+ id: 'actions',
+ Header: '',
+ Cell: ({ cell }) => (
+
+ } />
+
+ ),
+ className: 'actions',
+ width: 50,
+ disableResizing: true,
+ },
+ ],
+ [actionMenuList, formatMessage],
+ );
+
+ const handleDataTableFetchData = useCallback(
+ (...args) => {
+ onFetchData && onFetchData(...args);
+ },
+ [onFetchData],
+ );
+ const handleSelectedRowsChange = useCallback(
+ (selectedRows) => {
+ onSelectedRowsChange &&
+ onSelectedRowsChange(selectedRows.map((s) => s.original));
+ },
+ [onSelectedRowsChange],
+ );
+
+ const selectionColumn = useMemo(
+ () => ({
+ minWidth: 40,
+ width: 40,
+ maxWidth: 40,
+ }),
+ [],
+ );
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withRouter,
+ withCurrentView,
+ withDialogActions,
+ withDashboardActions,
+ withInvoiceActions,
+ withInvoices(
+ ({ invoicesCurrentPage, invoicesLoading, invoicesPageination }) => ({
+ invoicesCurrentPage,
+ invoicesLoading,
+ invoicesPageination,
+ }),
+ ),
+ withViewDetails(),
+)(InvoicesDataTable);
diff --git a/client/src/containers/Sales/Invoice/withInvoiceActions.js b/client/src/containers/Sales/Invoice/withInvoiceActions.js
new file mode 100644
index 000000000..0efec2c14
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/withInvoiceActions.js
@@ -0,0 +1,31 @@
+import { connect } from 'react-redux';
+import {
+ submitInvoice,
+ editInvoice,
+ deleteInvoice,
+ fetchInvoice,
+ fetchInvoicesTable,
+} from 'store/Invoice/invoices.actions';
+import t from 'store/types';
+
+const mapDipatchToProps = (dispatch) => ({
+ requestSubmitInvoice: (form) => dispatch(submitInvoice({ form })),
+ requsetFetchInvoice: (id) => dispatch(fetchInvoice({ id })),
+ requestEditInvoice: (id, form) => dispatch(editInvoice( id, form )),
+ requestFetchInvoiceTable: (query = {}) =>
+ dispatch(fetchInvoicesTable({ query: { ...query } })),
+ requestDeleteInvoice: (id) => dispatch(deleteInvoice({ id })),
+
+ changeInvoiceView: (id) =>
+ dispatch({
+ type: t.INVOICES_SET_CURRENT_VIEW,
+ currentViewId: parseInt(id, 10),
+ }),
+ addInvoiceTableQueries: (_queries) =>
+ dispatch({
+ type: t.INVOICES_TABLE_QUERIES_ADD,
+ _queries,
+ }),
+});
+
+export default connect(null, mapDipatchToProps);
diff --git a/client/src/containers/Sales/Invoice/withInvoiceDetail.js b/client/src/containers/Sales/Invoice/withInvoiceDetail.js
new file mode 100644
index 000000000..421cfd9ce
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/withInvoiceDetail.js
@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import { getInvoiecsByIdFactory } from 'store/Invoice/invoices.selector';
+
+export default () => {
+ const getInvoiceById = getInvoiecsByIdFactory();
+
+ const mapStateToProps = (state, props) => ({
+ invoice: getInvoiceById(state, props),
+ });
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Sales/Invoice/withInvoices.js b/client/src/containers/Sales/Invoice/withInvoices.js
new file mode 100644
index 000000000..73946368c
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/withInvoices.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import { getResourceViews } from 'store/customViews/customViews.selectors';
+import {
+ getInvoiceCurrentPageFactory,
+ getInvoicePaginationMetaFactory,
+ getInvoiceTableQueryFactory,
+} from 'store/Invoice/invoices.selector';
+
+export default (mapState) => {
+ const getInvoicesItems = getInvoiceCurrentPageFactory();
+ const getInvoicesPaginationMeta = getInvoicePaginationMetaFactory();
+ const getInvoiceTableQuery = getInvoiceTableQueryFactory();
+
+ const mapStateToProps = (state, props) => {
+ const query = getInvoiceTableQuery(state, props);
+
+ const mapped = {
+ invoicesCurrentPage: getInvoicesItems(state, props, query),
+ invoicesViews: getResourceViews(state, props, 'sales_invoices'),
+ invoicesItems: state.sales_invoices.items,
+ invoicesTableQuery: query,
+ invoicesPageination: getInvoicesPaginationMeta(state, props, query),
+ invoicesLoading: state.sales_invoices.loading,
+ };
+ return mapState ? mapState(mapped, state, props) : mapped;
+ };
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Sales/PaymentReceive/PaymentReceiveForm.js b/client/src/containers/Sales/PaymentReceive/PaymentReceiveForm.js
new file mode 100644
index 000000000..c96998400
--- /dev/null
+++ b/client/src/containers/Sales/PaymentReceive/PaymentReceiveForm.js
@@ -0,0 +1,260 @@
+import React, { useMemo, useCallback, useEffect, useState,useRef } from 'react';
+
+import * as Yup from 'yup';
+import { useFormik } from 'formik';
+import moment from 'moment';
+import { Intent, FormGroup, TextArea } from '@blueprintjs/core';
+
+import { FormattedMessage as T, useIntl } from 'react-intl';
+
+import PaymentReceiveHeader from './PaymentReceiveFormHeader';
+// PaymentReceiptItemsTable
+import PaymentReceiveFooter from './PaymentReceiveFormFooter';
+
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withMediaActions from 'containers/Media/withMediaActions';
+import withPaymentReceivesActions from './withPaymentReceivesActions'
+
+import { AppToaster } from 'components';
+import Dragzone from 'components/Dragzone';
+import useMedia from 'hooks/useMedia';
+
+import { compose, repeatValue } from 'utils';
+
+const MIN_LINES_NUMBER = 4;
+
+function PaymentReceiveForm({
+ //#withMedia
+ requestSubmitMedia,
+ requestDeleteMedia,
+
+ //#WithPaymentReceiveActions
+ requestSubmitPaymentReceive,
+
+
+ //#withDashboard
+ changePageTitle,
+ changePageSubtitle,
+
+ //#withPaymentReceiveDetail
+
+ //#OWn Props
+ payment_receive,
+ onFormSubmit,
+ onCancelForm,
+}) {
+ const { formatMessage } = useIntl();
+ const [payload, setPayload] = useState({});
+
+ const {
+ setFiles,
+ saveMedia,
+ deletedFiles,
+ setDeletedFiles,
+ deleteMedia,
+ } = useMedia({
+ saveCallback: requestSubmitMedia,
+ deleteCallback: requestDeleteMedia,
+ });
+
+ const savedMediaIds = useRef([]);
+ const clearSavedMediaIds = () => {
+ savedMediaIds.current = [];
+ };
+
+ useEffect(() => {
+ if (payment_receive && payment_receive.id) {
+ changePageTitle(formatMessage({ id: 'edit_payment_receive' }));
+ } else {
+ changePageTitle(formatMessage({ id: 'new_payment_receive' }));
+ }
+ }, [changePageTitle, payment_receive, formatMessage]);
+
+ const validationSchema = Yup.object().shape({
+ customer_id: Yup.string()
+ .label(formatMessage({ id: 'customer_name_' }))
+ .required(),
+ deposit_account_id: Yup.number()
+ .required()
+ .label(formatMessage({ id: 'deposit_account_' })),
+ payment_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'payment_date_' })),
+ payment_receive_no: Yup.number()
+ .required()
+ .label(formatMessage({ id: 'payment_receive_no_' })),
+ reference_no: Yup.string().min(1).max(255),
+ statement: Yup.string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .label(formatMessage({ id: 'statement' })),
+
+ entries: Yup.array().of(
+ Yup.object().shape({
+ payment_amount: Yup.number().nullable(),
+ item_id: Yup.number()
+ .nullable()
+ .when(['payment_amount'], {
+ is: (payment_amount) => payment_amount,
+ then: Yup.number().required(),
+ }),
+ description: Yup.string().nullable(),
+ }),
+ ),
+ });
+
+ const handleDropFiles = useCallback((_files) => {
+ setFiles(_files.filter((file) => file.uploaded === false));
+ }, []);
+
+ const savePaymentReceiveSubmit = useCallback((payload) => {
+ onFormSubmit && onFormSubmit(payload);
+ });
+
+ const defaultPaymentReceive = useMemo(
+ () => ({
+ item_id: null,
+ payment_amount: null,
+ description: null,
+ }),
+ [],
+ );
+ const defaultInitialValues = useMemo(
+ () => ({
+ customer_id: '',
+ deposit_account_id: '',
+ payment_date: moment(new Date()).format('YYYY-MM-DD'),
+ reference_no: '',
+ statement: '',
+ entries: [...repeatValue(defaultPaymentReceive, MIN_LINES_NUMBER)],
+ }),
+ [defaultPaymentReceive],
+ );
+
+ const initialValues = useMemo(
+ () => ({
+ ...defaultInitialValues,
+ entries: defaultInitialValues.entries,
+ }),
+ [defaultPaymentReceive, defaultInitialValues, payment_receive],
+ );
+
+ const initialAttachmentFiles = useMemo(() => {
+ return payment_receive && payment_receive.media
+ ? payment_receive.media.map((attach) => ({
+ preview: attach.attachment_file,
+ uploaded: true,
+ metadata: { ...attach },
+ }))
+ : [];
+ }, [payment_receive]);
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ validationSchema,
+ initialValues: {
+ ...initialValues,
+ },
+ onSubmit: async (values, { setSubmitting, setErrors, resetForm }) => {
+ const form = {
+ ...values,
+ };
+ const savePaymentReceive = (mediaIds) =>
+ new Promise((resolve, reject) => {
+ const requestForm = { ...form, media_ids: mediaIds };
+
+ requestSubmitPaymentReceive(requestForm)
+ .the((response) => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_payment_receive_has_been_successfully_created',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ clearSavedMediaIds();
+ savePaymentReceiveSubmit({ action: 'new', ...payload });
+ })
+ .catch((errors) => {
+ setSubmitting(false);
+ });
+ });
+ Promise.all([saveMedia(), deleteMedia()])
+ .then(([savedMediaResponses]) => {
+ const mediaIds = savedMediaResponses.map((res) => res.data.media.id);
+ savedMediaIds.current = mediaIds;
+ return savedMediaResponses;
+ })
+ .then(() => {
+ return savePaymentReceive(savedMediaIds.current);
+ });
+ },
+ });
+
+ const handleDeleteFile = useCallback(
+ (_deletedFiles) => {
+ _deletedFiles.forEach((deletedFile) => {
+ if (deletedFile.upload && deletedFile.metadata.id) {
+ setDeletedFiles([...deletedFiles, deletedFile.metadata.id]);
+ }
+ });
+ },
+ [setDeletedFiles, deletedFiles],
+ );
+
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPayload(payload);
+ formik.submitForm();
+ },
+ [setPayload, formik],
+ );
+
+ const handleCancelClick = useCallback(
+ (payload) => {
+ onCancelForm && onCancelForm(payload);
+ },
+ [onCancelForm],
+ );
+
+ const handleClearClick = () => {
+ formik.resetForm();
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withPaymentReceivesActions,
+ withDashboardActions,
+ withMediaActions,
+)(PaymentReceiveForm);
diff --git a/client/src/containers/Sales/PaymentReceive/PaymentReceiveFormFooter.js b/client/src/containers/Sales/PaymentReceive/PaymentReceiveFormFooter.js
new file mode 100644
index 000000000..793b43068
--- /dev/null
+++ b/client/src/containers/Sales/PaymentReceive/PaymentReceiveFormFooter.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import { Intent, Button } from '@blueprintjs/core';
+import { FormattedMessage as T } from 'react-intl';
+
+export default function PaymentReceiveFormFooter({
+ formik: { isSubmitting, resetForm },
+ onSubmitClick,
+ onCancelClick,
+ onClearClick,
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/Sales/PaymentReceive/PaymentReceiveFormHeader.js b/client/src/containers/Sales/PaymentReceive/PaymentReceiveFormHeader.js
new file mode 100644
index 000000000..fdda71e9a
--- /dev/null
+++ b/client/src/containers/Sales/PaymentReceive/PaymentReceiveFormHeader.js
@@ -0,0 +1,204 @@
+import React, { useMemo, useCallback, useState } from 'react';
+import {
+ FormGroup,
+ InputGroup,
+ Intent,
+ Position,
+ MenuItem,
+ Classes,
+} from '@blueprintjs/core';
+
+import { DateInput } from '@blueprintjs/datetime';
+import { FormattedMessage as T } from 'react-intl';
+import moment from 'moment';
+import { momentFormatter, compose, tansformDateValue } from 'utils';
+import classNames from 'classnames';
+import {
+ AccountsSelectList,
+ ListSelect,
+ ErrorMessage,
+ FieldRequiredHint,
+} from 'components';
+
+import withCustomers from 'containers/Customers/withCustomers';
+import withAccounts from 'containers/Accounts/withAccounts';
+
+function PaymentReceiveFormHeader({
+ formik: { errors, touched, setFieldValue, getFieldProps, values },
+
+ //#withCustomers
+ customers,
+ //#withAccouts
+ accountsList,
+}) {
+ const handleDateChange = useCallback(
+ (date_filed) => (date) => {
+ const formatted = moment(date).format('YYYY-MM-DD');
+ setFieldValue(date_filed, formatted);
+ },
+ [setFieldValue],
+ );
+
+ const handleCusomterRenderer = useCallback(
+ (custom, { handleClick }) => (
+
+ ),
+ [],
+ );
+
+ const handleFilterCustomer = (query, customer, index, exactMatch) => {
+ const normalizedTitle = customer.display_name.toLowerCase();
+ const normalizedQuery = query.toLowerCase();
+ if (exactMatch) {
+ return normalizedTitle === normalizedQuery;
+ } else {
+ return (
+ `${customer.display_name} ${normalizedTitle}`.indexOf(
+ normalizedQuery,
+ ) >= 0
+ );
+ }
+ };
+
+ const onChangeSelect = useCallback(
+ (filedName) => {
+ return (item) => {
+ setFieldValue(filedName, item.id);
+ };
+ },
+ [setFieldValue],
+ );
+
+ // Filter deposit accounts.
+ const depositAccounts = useMemo(
+ () => accountsList.filter((a) => a?.type?.key === 'current_asset'),
+ [accountsList],
+ );
+
+ return (
+
+
+ {/* Customer name */}
+
}
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ labelInfo={
}
+ intent={errors.customer_id && touched.customer_id && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
}
+ itemRenderer={handleCusomterRenderer}
+ itemPredicate={handleFilterCustomer}
+ popoverProps={{ minimal: true }}
+ onItemSelect={onChangeSelect('customer_id')}
+ selectedItem={values.customer_id}
+ selectedItemProp={'id'}
+ defaultText={
}
+ labelProp={'display_name'}
+ />
+
+
}
+ className={classNames(
+ 'form-group--deposit_account_id',
+ 'form-group--select-list',
+ Classes.FILL,
+ )}
+ inline={true}
+ labelInfo={
}
+ intent={
+ errors.deposit_account_id &&
+ touched.deposit_account_id &&
+ Intent.DANGER
+ }
+ helperText={
+
+ }
+ >
+
}
+ selectedAccountId={values.deposit_account_id}
+ />
+
+
}
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={errors.payment_date && touched.payment_date && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+ {/* payment receive no */}
+
}
+ inline={true}
+ className={('form-group--payment_receive_no', Classes.FILL)}
+ labelInfo={
}
+ intent={
+ errors.payment_receive_no &&
+ touched.payment_receive_no &&
+ Intent.DANGER
+ }
+ helperText={
+
+ }
+ >
+
+
+ {/* reference_no */}
+
}
+ inline={true}
+ className={classNames('form-group--reference', Classes.FILL)}
+ intent={errors.reference_no && touched.reference_no && Intent.DANGER}
+ helperText={
}
+ >
+
+
+
+ );
+}
+
+export default compose(
+ withCustomers(({ customers }) => ({
+ customers,
+ })),
+ withAccounts(({ accountsList }) => ({
+ accountsList,
+ })),
+)(PaymentReceiveFormHeader);
diff --git a/client/src/containers/Sales/PaymentReceive/PaymentReceiveItemsTable.js b/client/src/containers/Sales/PaymentReceive/PaymentReceiveItemsTable.js
new file mode 100644
index 000000000..777cecb48
--- /dev/null
+++ b/client/src/containers/Sales/PaymentReceive/PaymentReceiveItemsTable.js
@@ -0,0 +1,13 @@
+import React, { useState, useMemo, useEffect, useCallback } from 'react';
+import { Button, Intent, Position, Tooltip } from '@blueprintjs/core';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import DataTable from 'components/DataTable';
+import Icon from 'components/Icon';
+
+import { compose, formattedAmount, transformUpdatedRows } from 'utils';
+import {
+ InputGroupCell,
+ MoneyFieldCell,
+ EstimatesListFieldCell,
+ DivFieldCell,
+} from 'components/DataTableCells';
diff --git a/client/src/containers/Sales/PaymentReceive/PaymentReceives.js b/client/src/containers/Sales/PaymentReceive/PaymentReceives.js
new file mode 100644
index 000000000..48779ea57
--- /dev/null
+++ b/client/src/containers/Sales/PaymentReceive/PaymentReceives.js
@@ -0,0 +1,69 @@
+import React, { useCallback } from 'react';
+import { useParams, useHistory } from 'react-router-dom';
+import { useQuery } from 'react-query';
+
+import PaymentReceiveForm from './PaymentReceiveForm';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import withCustomersActions from 'containers/Customers/withCustomersActions';
+import withAccountsActions from 'containers/Accounts/withAccountsActions';
+import withItemsActions from 'containers/Items/withItemsActions';
+//#withInvoiceActions
+
+import { compose } from 'utils';
+
+function PaymentReceives({
+ //#withwithAccountsActions
+ requestFetchAccounts,
+
+ //#withCustomersActions
+ requestFetchCustomers,
+
+ //#withItemsActions
+ requestFetchItems,
+
+ //#withInvoiceActions
+}) {
+ const history = useHistory();
+
+ // Handle fetch accounts data
+ const fetchAccounts = useQuery('accounts-list', (key) =>
+ requestFetchAccounts(),
+ );
+
+ // Handle fetch Items data table or list
+ const fetchItems = useQuery('items-table', () => requestFetchItems({}));
+
+ // Handle fetch customers data table or list
+ const fetchCustomers = useQuery('customers-table', () =>
+ requestFetchCustomers({}),
+ );
+
+ const handleFormSubmit = useCallback((payload) => {}, [history]);
+
+ const handleCancel = useCallback(() => {
+ history.goBack();
+ }, [history]);
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withCustomersActions,
+ withItemsActions,
+ withAccountsActions,
+ // withInvoiceActions
+)(PaymentReceives);
diff --git a/client/src/containers/Sales/PaymentReceive/withPaymentReceivesActions.js b/client/src/containers/Sales/PaymentReceive/withPaymentReceivesActions.js
new file mode 100644
index 000000000..393fdfc86
--- /dev/null
+++ b/client/src/containers/Sales/PaymentReceive/withPaymentReceivesActions.js
@@ -0,0 +1,34 @@
+import { connect } from 'react-redux';
+import {
+ submitPaymentReceive,
+ editPaymentReceive,
+ deletePaymentReceive,
+ fetchPaymentReceive,
+ fetchPaymentReceivesTable,
+} from 'store/PaymentReceive/paymentReceive.actions';
+import t from 'store/types';
+
+const mapDispatchToProps = (dispatch) => ({
+ requestSubmitPaymentReceive: (form) =>
+ dispatch(submitPaymentReceive({ form })),
+ requestFetchPaymentReceive: (id) => dispatch(fetchPaymentReceive({ id })),
+ requestEditPaymentReceive: (id, form) =>
+ dispatch(editPaymentReceive({ id, form })),
+ requestDeletePaymentReceive: (id) => dispatch(deletePaymentReceive({ id })),
+ requestFetchPaymentReceiveTable: (query = {}) =>
+ dispatch(fetchPaymentReceivesTable({ query: { ...query } })),
+
+ changePaymentReceiveView: (id) =>
+ dispatch({
+ type: t.PAYMENT_RECEIVE_SET_CURRENT_VIEW,
+ currentViewId: parseInt(id, 10),
+ }),
+
+ addPaymentReceivesTableQueries: (queries) =>
+ dispatch({
+ type: t.PAYMENT_RECEIVE_TABLE_QUERIES_ADD,
+ queries,
+ }),
+});
+
+export default connect(null, mapDispatchToProps);
diff --git a/client/src/containers/Sales/Receipt/ReceiptActionsBar.js b/client/src/containers/Sales/Receipt/ReceiptActionsBar.js
new file mode 100644
index 000000000..107ae0988
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/ReceiptActionsBar.js
@@ -0,0 +1,149 @@
+import React, { useCallback, useState, useMemo } from 'react';
+import Icon from 'components/Icon';
+import {
+ Button,
+ Classes,
+ Menu,
+ MenuItem,
+ Popover,
+ NavbarDivider,
+ NavbarGroup,
+ PopoverInteractionKind,
+ Position,
+ Intent,
+} from '@blueprintjs/core';
+
+import classNames from 'classnames';
+import { useRouteMatch, useHistory } from 'react-router-dom';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+
+import { connect } from 'react-redux';
+import { If, DashboardActionViewsList } from 'components';
+import FilterDropdown from 'components/FilterDropdown';
+import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar';
+
+import withResourceDetail from 'containers/Resources/withResourceDetails';
+import withDialogActions from 'containers/Dialog/withDialogActions';
+import withReceiptActions from './withReceipActions';
+import withReceipts from './withReceipts';
+
+import { compose } from 'utils';
+
+function ReceiptActionsBar({
+ // #withResourceDetail
+ resourceFields,
+
+ //#withReceipts
+ receiptview,
+ //#withReceiptActions
+ addReceiptsTableQueries,
+
+ //#OWn Props
+ onFilterChanged,
+ selectedRows = [],
+}) {
+ const { path } = useRouteMatch();
+ const history = useHistory();
+ const [filterCount, setFilterCount] = useState(0);
+ const { formatMessage } = useIntl();
+
+ const onClickNewReceipt = useCallback(() => {
+ history.push('/receipts/new');
+ }, [history]);
+
+ // const filterDropdown = FilterDropdown({
+ // initialCondition: {
+ // fieldKey: '',
+ // compatator: '',
+ // value: '',
+ // },
+ // fields: resourceFields,
+ // onFilterChange: (filterConditions) => {
+ // addReceiptsTableQueries({
+ // filter_roles: filterConditions || '',
+ // });
+ // onFilterChanged && onFilterChange(filterConditions);
+ // },
+ // });
+
+ const hasSelectedRows = useMemo(() => selectedRows.length > 0, [
+ selectedRows,
+ ]);
+
+ return (
+
+
+
+
+
+ }
+ text={}
+ onClick={onClickNewReceipt}
+ />
+
+
+ ) : (
+ `${filterCount} ${formatMessage({ id: 'filters_applied' })}`
+ )
+ }
+ icon={}
+ />
+
+
+ }
+ text={}
+ intent={Intent.DANGER}
+ />
+
+ }
+ text={}
+ />
+
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+
+
+ );
+}
+const mapStateToProps = (state, props) => ({
+ resourceName: 'sales_receipts',
+});
+
+const withReceiptActionsBar = connect(mapStateToProps);
+
+export default compose(
+ withReceiptActionsBar,
+ withResourceDetail(({ resourceFields }) => ({
+ resourceFields,
+ })),
+ withReceipts(({ receiptview }) => ({
+ receiptview,
+ })),
+ withReceiptActions,
+)(ReceiptActionsBar);
diff --git a/client/src/containers/Sales/Receipt/ReceiptForm.js b/client/src/containers/Sales/Receipt/ReceiptForm.js
new file mode 100644
index 000000000..8ac82ab52
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/ReceiptForm.js
@@ -0,0 +1,340 @@
+import React, {
+ useMemo,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+
+import * as Yup from 'yup';
+import { useFormik } from 'formik';
+import moment from 'moment';
+import { Intent, FormGroup, TextArea } from '@blueprintjs/core';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import { pick } from 'lodash';
+
+import ReceiptFromHeader from './ReceiptFormHeader';
+import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable';
+import ReceiptFormFooter from './ReceiptFormFooter';
+
+import withReceipActions from './withReceipActions';
+import withReceiptDetail from './withReceiptDetail';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withMediaActions from 'containers/Media/withMediaActions';
+
+import { AppToaster } from 'components';
+import Dragzone from 'components/Dragzone';
+import useMedia from 'hooks/useMedia';
+
+import { compose, repeatValue } from 'utils';
+
+const MIN_LINES_NUMBER = 4;
+
+function ReceiptForm({
+ //#withMedia
+ requestSubmitMedia,
+ requestDeleteMedia,
+
+ //#withReceiptActions
+ requestSubmitReceipt,
+ requestEditReceipt,
+
+ //#withReceiptDetail
+ receipt,
+
+ //#withDashboard
+ changePageTitle,
+ changePageSubtitle,
+
+ //#own Props
+ receiptId,
+ onFormSubmit,
+ onCancelForm,
+}) {
+ const { formatMessage } = useIntl();
+ const [payload, setPayload] = useState({});
+
+ const {
+ setFiles,
+ saveMedia,
+ deletedFiles,
+ setDeletedFiles,
+ deleteMedia,
+ } = useMedia({
+ saveCallback: requestSubmitMedia,
+ deleteCallback: requestDeleteMedia,
+ });
+
+ const handleDropFiles = useCallback((_files) => {
+ setFiles(_files.filter((file) => file.uploaded === false));
+ }, []);
+
+ const savedMediaIds = useRef([]);
+ const clearSavedMediaIds = () => {
+ savedMediaIds.current = [];
+ };
+
+ useEffect(() => {
+ if (receipt && receipt.id) {
+ changePageTitle(formatMessage({ id: 'edit_receipt' }));
+ } else {
+ changePageTitle(formatMessage({ id: 'new_receipt' }));
+ }
+ }, [changePageTitle, receipt, formatMessage]);
+
+ const validationSchema = Yup.object().shape({
+ customer_id: Yup.string()
+ .label(formatMessage({ id: 'customer_name_' }))
+ .required(),
+ receipt_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'receipt_date_' })),
+ // receipt_no: Yup.number()
+ // .required()
+ // .label(formatMessage({ id: 'receipt_no_' })),
+ deposit_account_id: Yup.number()
+ .required()
+ .label(formatMessage({ id: 'deposit_account_' })),
+ reference_no: Yup.string().min(1).max(255),
+ receipt_message: Yup.string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .label(formatMessage({ id: 'receipt_message_' })),
+ email_send_to: Yup.string().email().nullable(),
+ statement: Yup.string()
+ .trim()
+ .min(1)
+ .max(1024)
+ .label(formatMessage({ id: 'note' })),
+
+ entries: Yup.array().of(
+ Yup.object().shape({
+ quantity: Yup.number().nullable(),
+ rate: Yup.number().nullable(),
+ item_id: Yup.number()
+ .nullable()
+ .when(['quantity', 'rate'], {
+ is: (quantity, rate) => quantity || rate,
+ then: Yup.number().required(),
+ }),
+ discount: Yup.number().nullable(),
+ description: Yup.string().nullable(),
+ }),
+ ),
+ });
+ const saveInvokeSubmit = useCallback(
+ (payload) => {
+ onFormSubmit && onFormSubmit(payload);
+ },
+ [onFormSubmit],
+ );
+
+ const defaultReceipt = useMemo(
+ () => ({
+ index: 0,
+ item_id: null,
+ rate: null,
+ discount: 0,
+ quantity: null,
+ description: '',
+ }),
+ [],
+ );
+
+ const defaultInitialValues = useMemo(
+ () => ({
+ customer_id: '',
+ deposit_account_id: '',
+ receipt_date: moment(new Date()).format('YYYY-MM-DD'),
+ email_send_to: '',
+ reference_no: '',
+ receipt_message: '',
+ statement: '',
+ entries: [...repeatValue(defaultReceipt, MIN_LINES_NUMBER)],
+ }),
+ [defaultReceipt],
+ );
+
+ const orderingIndex = (_receipt) => {
+ return _receipt.map((item, index) => ({
+ ...item,
+ index: index + 1,
+ }));
+ };
+
+ const initialValues = useMemo(
+ () => ({
+ ...(receipt
+ ? {
+ ...pick(receipt, Object.keys(defaultInitialValues)),
+ entries: [
+ ...receipt.entries.map((receipt) => ({
+ ...pick(receipt, Object.keys(defaultReceipt)),
+ })),
+ ...repeatValue(
+ defaultReceipt,
+ Math.max(MIN_LINES_NUMBER - receipt.entries.length, 0),
+ ),
+ ],
+ }
+ : {
+ ...defaultInitialValues,
+ entries: orderingIndex(defaultInitialValues.entries),
+ }),
+ }),
+ [receipt, defaultInitialValues, defaultReceipt],
+ );
+
+ const initialAttachmentFiles = useMemo(() => {
+ return receipt && receipt.media
+ ? receipt.media.map((attach) => ({
+ preview: attach.attachment_file,
+ uploaded: true,
+ metadata: { ...attach },
+ }))
+ : [];
+ }, [receipt]);
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ validationSchema,
+ initialValues: {
+ ...initialValues,
+ },
+ onSubmit: async (values, { setErrors, setSubmitting, resetForm }) => {
+ const entries = values.entries.filter(
+ (item) => item.item_id && item.quantity,
+ );
+ const form = {
+ ...values,
+ entries,
+ };
+
+ const requestForm = { ...form };
+
+ if (receipt && receipt.id) {
+ requestEditReceipt(receipt.id && requestForm).then(() => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_receipt_has_been_successfully_edited',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ saveInvokeSubmit({ action: 'update', ...payload });
+ resetForm();
+ });
+ } else {
+ requestSubmitReceipt(requestForm)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_receipt_has_been_successfully_created',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ saveInvokeSubmit({ action: 'new', ...payload });
+ resetForm();
+ })
+ .catch((errors) => {
+ setSubmitting(false);
+ });
+ }
+ },
+ });
+
+ const handleDeleteFile = useCallback(
+ (_deletedFiles) => {
+ _deletedFiles.forEach((deletedFile) => {
+ if (deletedFile.uploaded && deletedFile.metadata.id) {
+ setDeletedFiles([...deletedFiles, deletedFile.metadata.id]);
+ }
+ });
+ },
+ [setDeletedFiles, deletedFiles],
+ );
+
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPayload(payload);
+ formik.submitForm();
+ },
+ [setPayload, formik],
+ );
+
+ const handleCancelClick = useCallback(
+ (payload) => {
+ onCancelForm && onCancelForm(payload);
+ },
+ [onCancelForm],
+ );
+
+ const handleClickAddNewRow = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingIndex([...formik.values.entries, defaultReceipt]),
+ );
+ };
+
+ const handleClearAllLines = () => {
+ formik.setFieldValue(
+ 'entries',
+ orderingIndex([...repeatValue(defaultReceipt, MIN_LINES_NUMBER)]),
+ );
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+export default compose(
+ withReceipActions,
+ withDashboardActions,
+ withMediaActions,
+ withReceiptDetail(),
+)(ReceiptForm);
diff --git a/client/src/containers/Sales/Receipt/ReceiptFormFooter.js b/client/src/containers/Sales/Receipt/ReceiptFormFooter.js
new file mode 100644
index 000000000..26c09555e
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/ReceiptFormFooter.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import { Intent, Button } from '@blueprintjs/core';
+import { FormattedMessage as T } from 'react-intl';
+
+export default function ReceiptFormFooter({
+ formik: { isSubmitting },
+ onSubmitClick,
+ onCancelClick,
+ receipt,
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/containers/Sales/Receipt/ReceiptFormHeader.js b/client/src/containers/Sales/Receipt/ReceiptFormHeader.js
new file mode 100644
index 000000000..871ef41a6
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/ReceiptFormHeader.js
@@ -0,0 +1,214 @@
+import React, { useMemo, useCallback, useState } from 'react';
+import {
+ FormGroup,
+ InputGroup,
+ Intent,
+ Position,
+ MenuItem,
+ Classes,
+} from '@blueprintjs/core';
+
+import { DateInput } from '@blueprintjs/datetime';
+import { FormattedMessage as T } from 'react-intl';
+import moment from 'moment';
+import { momentFormatter, compose, tansformDateValue } from 'utils';
+import classNames from 'classnames';
+import {
+ AccountsSelectList,
+ ListSelect,
+ ErrorMessage,
+ FieldRequiredHint,
+ Hint,
+} from 'components';
+
+import withCustomers from 'containers/Customers/withCustomers';
+import withAccounts from 'containers/Accounts/withAccounts';
+
+function ReceiptFormHeader({
+ formik: { errors, touched, setFieldValue, getFieldProps, values },
+
+ //#withCustomers
+ customers,
+ //#withAccouts
+ accountsList,
+}) {
+ const handleDateChange = useCallback(
+ (date) => {
+ const formatted = moment(date).format('YYYY-MM-DD');
+ setFieldValue('receipt_date', formatted);
+ },
+ [setFieldValue],
+ );
+
+ const CustomerRenderer = useCallback(
+ (cutomer, { handleClick }) => (
+
+ ),
+ [],
+ );
+
+ // Filter Customer
+ const filterCustomer = (query, customer, _index, exactMatch) => {
+ const normalizedTitle = customer.display_name.toLowerCase();
+ const normalizedQuery = query.toLowerCase();
+ if (exactMatch) {
+ return normalizedTitle === normalizedQuery;
+ } else {
+ return (
+ `${customer.display_name} ${normalizedTitle}`.indexOf(
+ normalizedQuery,
+ ) >= 0
+ );
+ }
+ };
+
+ // handle change
+ const onChangeSelect = useCallback(
+ (filedName) => {
+ return (item) => {
+ setFieldValue(filedName, item.id);
+ };
+ },
+ [setFieldValue],
+ );
+
+ // Filter deposit accounts.
+ const depositAccounts = useMemo(
+ () => accountsList.filter((a) => a?.type?.key === 'current_asset'),
+ [accountsList],
+ );
+
+ return (
+
+
+ {/* customer name */}
+
}
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ labelInfo={
}
+ intent={errors.customer_id && touched.customer_id && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
}
+ itemRenderer={CustomerRenderer}
+ itemPredicate={filterCustomer}
+ popoverProps={{ minimal: true }}
+ onItemSelect={onChangeSelect('customer_id')}
+ selectedItem={values.customer_id}
+ selectedItemProp={'id'}
+ defaultText={
}
+ labelProp={'display_name'}
+ />
+
+
+
}
+ className={classNames(
+ 'form-group--deposit_account_id',
+ 'form-group--select-list',
+ Classes.FILL,
+ )}
+ inline={true}
+ labelInfo={
}
+ intent={
+ errors.deposit_account_id &&
+ touched.deposit_account_id &&
+ Intent.DANGER
+ }
+ helperText={
+
+ }
+ >
+
}
+ selectedAccountId={values.deposit_account_id}
+ />
+
+
+
}
+ inline={true}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={errors.receipt_date && touched.receipt_date && Intent.DANGER}
+ helperText={
+
+ }
+ >
+
+
+
+ {/* receipt_no */}
+ {/*
}
+ inline={true}
+ className={('form-group--receipt_no', Classes.FILL)}
+ labelInfo={
}
+ intent={errors.receipt_no && touched.receipt_no && Intent.DANGER}
+ helperText={
}
+ >
+
+ */}
+
+
}
+ inline={true}
+ className={classNames('form-group--reference', Classes.FILL)}
+ intent={errors.reference_no && touched.reference_no && Intent.DANGER}
+ helperText={
}
+ >
+
+
+
}
+ inline={true}
+ className={classNames('form-group--send_to_email', Classes.FILL)}
+ intent={errors.email_send_to && touched.email_send_to && Intent.DANGER}
+ helperText={
}
+ >
+
+
+
+ );
+}
+
+export default compose(
+ withCustomers(({ customers }) => ({
+ customers,
+ })),
+ withAccounts(({ accountsList }) => ({
+ accountsList,
+ })),
+)(ReceiptFormHeader);
diff --git a/client/src/containers/Sales/Receipt/ReceiptList.js b/client/src/containers/Sales/Receipt/ReceiptList.js
new file mode 100644
index 000000000..6df1324aa
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/ReceiptList.js
@@ -0,0 +1,191 @@
+import React, { useEffect, useCallback, useMemo, useState } from 'react';
+import { Route, Switch, useHistory } from 'react-router-dom';
+import { useQuery, queryCache } from 'react-query';
+import { Alert, Intent } from '@blueprintjs/core';
+
+import AppToaster from 'components/AppToaster';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import DashboardPageContent from 'components/Dashboard/DashboardPageContent';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import ReceiptsDataTable from './ReceiptsDataTable';
+import ReceiptActionsBar from './ReceiptActionsBar';
+import ReceiptViewTabs from './ReceiptViewTabs';
+
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withResourceActions from 'containers/Resources/withResourcesActions';
+import withReceipts from './withReceipts';
+import withReceipActions from './withReceipActions';
+import withViewsActions from 'containers/Views/withViewsActions';
+
+import { compose } from 'utils';
+
+function ReceiptList({
+ // #withDashboardActions
+ changePageTitle,
+
+ // #withViewsActions
+ requestFetchResourceViews,
+ requestFetchResourceFields,
+
+ //#withReceipts
+ receiptTableQuery,
+ receiptview,
+
+ //#withReceiptActions
+ requestFetchReceiptsTable,
+ requestDeleteReceipt,
+ addReceiptsTableQueries,
+}) {
+ const history = useHistory();
+ const { formatMessage } = useIntl();
+ const [deleteReceipt, setDeleteReceipt] = useState(false);
+ const [selectedRows, setSelectedRows] = useState([]);
+
+ const fetchReceipts = useQuery(['receipts-table', receiptTableQuery], () =>
+ requestFetchReceiptsTable(),
+ );
+
+ const fetchResourceViews = useQuery(
+ ['resource-views', 'sales_receipts'],
+ (key, resourceName) => requestFetchResourceViews(resourceName),
+ );
+
+ const fetchResourceFields = useQuery(
+ ['resource-fields', 'sales_receipts'],
+ (key, resourceName) => requestFetchResourceFields(resourceName),
+ );
+
+ useEffect(() => {
+ changePageTitle(formatMessage({ id: 'receipt_list' }));
+ }, [changePageTitle, formatMessage]);
+
+ // handle delete receipt click
+ const handleDeleteReceipt = useCallback(
+ (_receipt) => {
+ setDeleteReceipt(_receipt);
+ },
+ [setDeleteReceipt],
+ );
+
+ // handle cancel receipt
+ const handleCancelReceiptDelete = useCallback(() => {
+ setDeleteReceipt(false);
+ }, [setDeleteReceipt]);
+
+ // handle confirm delete receipt
+ const handleConfirmReceiptDelete = useCallback(() => {
+ requestDeleteReceipt(deleteReceipt.id).then(() => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_receipt_has_been_successfully_deleted',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setDeleteReceipt(false);
+ });
+ }, [deleteReceipt, requestDeleteReceipt, formatMessage]);
+
+ // Handle filter change to re-fetch data-table.
+ // const handleFilterChanged = useCallback(
+ // (filterConditions) => {
+ // addReceiptsTableQueries({
+ // filter_roles: filterConditions || '',
+ // });
+ // },
+ // [fetchReceipt],
+ // );
+
+ // Handle filter change to re-fetch data-table.
+ const handleFilterChanged = useCallback(() => {}, [fetchReceipts]);
+
+
+
+ // Calculates the selected rows
+ const selectedRowsCount = useMemo(() => Object.values(selectedRows).length, [
+ selectedRows,
+ ]);
+
+ const handleEditReceipt = useCallback(
+ (receipt) => {
+ history.push(`/receipts/${receipt.id}/edit`);
+ },
+ [history],
+ );
+ const handleFetchData = useCallback(
+ ({ pageIndex, pageSize, sortBy }) => {
+ const page = pageIndex + 1;
+
+ addReceiptsTableQueries({
+ ...(sortBy.length > 0
+ ? {
+ column_sort_by: sortBy[0].id,
+ sort_order: sortBy[0].desc ? 'desc' : 'asc',
+ }
+ : {}),
+ page_size: pageSize,
+ page,
+ });
+ },
+ [addReceiptsTableQueries],
+ );
+
+ const handleSelectedRowsChange = useCallback(
+ (estimate) => {
+ setSelectedRows(estimate);
+ },
+ [setSelectedRows],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+ }
+ confirmButtonText={}
+ icon={'trash'}
+ intent={Intent.DANGER}
+ isOpen={deleteReceipt}
+ onCancel={handleCancelReceiptDelete}
+ onConfirm={handleConfirmReceiptDelete}
+ >
+
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withResourceActions,
+ withReceipActions,
+ withDashboardActions,
+ withViewsActions,
+ withReceipts(({ receiptTableQuery }) => ({
+ receiptTableQuery,
+ })),
+)(ReceiptList);
diff --git a/client/src/containers/Sales/Receipt/ReceiptViewTabs.js b/client/src/containers/Sales/Receipt/ReceiptViewTabs.js
new file mode 100644
index 000000000..40b8ab424
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/ReceiptViewTabs.js
@@ -0,0 +1,109 @@
+import React, { useEffect, useRef } from 'react';
+import { useHistory } from 'react-router';
+import { Alignment, Navbar, NavbarGroup } from '@blueprintjs/core';
+import { useParams, withRouter } from 'react-router-dom';
+import { connect } from 'react-redux';
+import { pick, debounce } from 'lodash';
+
+import { DashboardViewsTabs } from 'components';
+import { useUpdateEffect } from 'hooks';
+
+import withReceipts from './withReceipts';
+import withReceiptActions from './withReceipActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+
+import { compose } from 'utils';
+
+function ReceiptViewTabs({
+ //#withReceipts
+ receiptview,
+ // #withViewDetails
+ viewItem,
+
+ //#withReceiptActions
+ changeReceiptView,
+ addReceiptsTableQueries,
+
+ // #withDashboardActions
+ setTopbarEditView,
+ changePageSubtitle,
+
+ //# own Props
+ customViewChanged,
+ onViewChanged,
+}) {
+ const history = useHistory();
+ const { custom_view_id: customViewId = null } = useParams();
+
+ useEffect(() => {
+ changeReceiptView(customViewId || -1);
+ setTopbarEditView(customViewId);
+ changePageSubtitle(customViewId && viewItem ? viewItem.name : '');
+
+ addReceiptsTableQueries({
+ custom_view_id: customViewId,
+ });
+ return () => {
+ setTopbarEditView(null);
+ changePageSubtitle('');
+ changeReceiptView(null);
+ };
+ }, [customViewId, addReceiptsTableQueries, changeReceiptView]);
+
+ useUpdateEffect(() => {
+ onViewChanged && onViewChanged(customViewId);
+ }, [customViewId]);
+
+ const debounceChangeHistory = useRef(
+ debounce((toUrl) => {
+ history.push(toUrl);
+ }, 250),
+ );
+
+ const handleTabsChange = (viewId) => {
+ const toPath = viewId ? `${viewId}/custom_view` : '';
+ debounceChangeHistory.current(`/receipts/${toPath}`);
+ setTopbarEditView(viewId);
+ };
+ const tabs = receiptview.map((view) => ({
+ ...pick(view, ['name', 'id']),
+ }));
+
+ // Handle click a new view tab.
+ const handleClickNewView = () => {
+ setTopbarEditView(null);
+ history.push('/custom_views/receipts/new');
+ };
+
+ console.log(receiptview, 'receiptview');
+
+ return (
+
+
+
+
+
+ );
+}
+
+const mapStateToProps = (state, ownProps) => ({
+ viewId: ownProps.match.params.custom_view_id,
+});
+
+const withReceiptsViewTabs = connect(mapStateToProps);
+
+export default compose(
+ withRouter,
+ withReceiptsViewTabs,
+ withReceiptActions,
+ withDashboardActions,
+ withViewDetails(),
+ withReceipts(({ receiptview }) => ({ receiptview })),
+)(ReceiptViewTabs);
diff --git a/client/src/containers/Sales/Receipt/Receipts.js b/client/src/containers/Sales/Receipt/Receipts.js
new file mode 100644
index 000000000..65dc6f1fc
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/Receipts.js
@@ -0,0 +1,82 @@
+import React, { useCallback } from 'react';
+import { useParams, useHistory } from 'react-router-dom';
+import { useQuery } from 'react-query';
+
+import ReceiptFrom from './ReceiptForm';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import withCustomersActions from 'containers/Customers/withCustomersActions';
+import withAccountsActions from 'containers/Accounts/withAccountsActions';
+import withItemsActions from 'containers/Items/withItemsActions';
+import withReceipActions from './withReceipActions';
+
+import { compose } from 'utils';
+
+function Receipts({
+ //#withwithAccountsActions
+ requestFetchAccounts,
+
+ //#withCustomersActions
+ requestFetchCustomers,
+
+ //#withItemsActions
+ requestFetchItems,
+
+ //#withReceiptsActions
+ requsetFetchInvoice,
+}) {
+ const history = useHistory();
+ const { id } = useParams();
+
+ const fetchReceipt = useQuery(
+ ['receipt', id],
+ (key, _id) => requsetFetchInvoice(_id),
+ { enabled: !!id },
+ );
+ const fetchAccounts = useQuery('accounts-list', (key) =>
+ requestFetchAccounts(),
+ );
+
+ const fetchCustomers = useQuery('customers-table', () =>
+ requestFetchCustomers({}),
+ );
+
+ // Handle fetch Items data table or list
+ const fetchItems = useQuery('items-table', () => requestFetchItems({}));
+
+ const handleFormSubmit = useCallback(
+ (payload) => {
+ payload.redirect && history.push('/receipts');
+ },
+ [history],
+ );
+
+ const handleCancel = useCallback(() => {
+ history.goBack();
+ }, [history]);
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withReceipActions,
+ withCustomersActions,
+ withItemsActions,
+ withAccountsActions,
+)(Receipts);
diff --git a/client/src/containers/Sales/Receipt/ReceiptsDataTable.js b/client/src/containers/Sales/Receipt/ReceiptsDataTable.js
new file mode 100644
index 000000000..d1ec2939b
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/ReceiptsDataTable.js
@@ -0,0 +1,247 @@
+import React, { useEffect, useCallback, useState, useMemo } from 'react';
+import {
+ Intent,
+ Button,
+ Popover,
+ Menu,
+ MenuItem,
+ MenuDivider,
+ Position,
+} from '@blueprintjs/core';
+import { useParams } from 'react-router-dom';
+import { withRouter } from 'react-router';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import moment from 'moment';
+
+import { compose } from 'utils';
+import { useUpdateEffect } from 'hooks';
+
+import LoadingIndicator from 'components/LoadingIndicator';
+import { DataTable, Money, Icon } from 'components';
+
+import withDialogActions from 'containers/Dialog/withDialogActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+
+import withReceipts from './withReceipts';
+import withReceipActions from './withReceipActions';
+import withCurrentView from 'containers/Views/withCurrentView';
+
+function ReceiptsDataTable({
+ //#withReceipts
+ receiptsCurrentPage,
+ receiptsLoading,
+ receiptsPagination,
+ receiptItems,
+ // #withDashboardActions
+ changeCurrentView,
+ changePageSubtitle,
+ setTopbarEditView,
+
+ // #withView
+ viewMeta,
+
+ // #Own Props
+
+ loading,
+ onFetchData,
+ onEditReceipt,
+ onDeleteReceipt,
+ onSelectedRowsChange,
+}) {
+ const [initialMount, setInitialMount] = useState(false);
+ const { custom_view_id: customViewId } = useParams();
+ const { formatMessage } = useIntl();
+
+ useUpdateEffect(() => {
+ if (!receiptsLoading) {
+ setInitialMount(true);
+ }
+ }, [receiptsLoading, setInitialMount]);
+
+ useEffect(() => {
+ setInitialMount(false);
+ }, []);
+
+ useEffect(() => {
+ if (customViewId) {
+ changeCurrentView(customViewId);
+ setTopbarEditView(customViewId);
+ }
+ changePageSubtitle(customViewId && viewMeta ? viewMeta.name : '');
+ }, [
+ customViewId,
+ changeCurrentView,
+ changePageSubtitle,
+ setTopbarEditView,
+ viewMeta,
+ ]);
+
+ const handleEditReceipt = useCallback(
+ (receipt) => () => {
+ onEditReceipt && onEditReceipt(receipt);
+ },
+ [onEditReceipt],
+ );
+
+ const handleDeleteReceipt = useCallback(
+ (receipt) => () => {
+ onDeleteReceipt && onDeleteReceipt(receipt);
+ },
+ [onDeleteReceipt],
+ );
+
+ const actionMenuList = useCallback(
+ (estimate) => (
+
+ ),
+ [handleDeleteReceipt, handleEditReceipt, formatMessage],
+ );
+
+ const onRowContextMenu = useCallback(
+ (cell) => {
+ return actionMenuList(cell.row.original);
+ },
+ [actionMenuList],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'receipt_date',
+ Header: formatMessage({ id: 'receipt_date' }),
+ accessor: (r) => moment(r.receipt_date).format('YYYY MMM DD'),
+ width: 140,
+ className: 'receipt_date',
+ },
+ {
+ id: 'customer_id',
+ Header: formatMessage({ id: 'customer_name' }),
+ accessor: 'customer.display_name',
+ width: 140,
+ className: 'customer_id',
+ },
+ {
+ id: 'deposit_account_id',
+ Header: formatMessage({ id: 'deposit_account' }),
+ accessor: 'deposit_account.name',
+ width: 140,
+ className: 'deposit_account',
+ },
+ {
+ id: 'email_send_to',
+ Header: formatMessage({ id: 'email' }),
+ accessor: 'email_send_to',
+ width: 140,
+ className: 'email_send_to',
+ },
+ {
+ id: 'amount',
+ Header: formatMessage({ id: 'amount' }),
+ accessor: (r) => ,
+
+ width: 140,
+ className: 'amount',
+ },
+ {
+ id: 'reference_no',
+ Header: formatMessage({ id: 'reference_no' }),
+ accessor: 'reference_no',
+ width: 140,
+ className: 'reference_no',
+ },
+ {
+ id: 'actions',
+ Header: '',
+ Cell: ({ cell }) => (
+
+ } />
+
+ ),
+ className: 'actions',
+ width: 50,
+ disableResizing: true,
+ },
+ ],
+ [actionMenuList, formatMessage],
+ );
+
+ const handleDataTableFetchData = useCallback(
+ (...args) => {
+ onFetchData && onFetchData(...args);
+ },
+ [onFetchData],
+ );
+
+ const handleSelectedRowsChange = useCallback(
+ (selectedRows) => {
+ onSelectedRowsChange &&
+ onSelectedRowsChange(selectedRows.map((s) => s.original));
+ },
+ [onSelectedRowsChange],
+ );
+
+ console.log(receiptsCurrentPage, 'receiptCurrnetPage');
+ console.log(receiptItems, 'receiptItems');
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withRouter,
+ withCurrentView,
+ withDialogActions,
+ withDashboardActions,
+ withReceipActions,
+ withReceipts(
+ ({
+ receiptsCurrentPage,
+ receiptsLoading,
+ receiptsPagination,
+ receiptItems,
+ }) => ({
+ receiptsCurrentPage,
+ receiptsLoading,
+ receiptsPagination,
+ receiptItems,
+ }),
+ ),
+ withViewDetails(),
+)(ReceiptsDataTable);
diff --git a/client/src/containers/Sales/Receipt/withReceipActions.js b/client/src/containers/Sales/Receipt/withReceipActions.js
new file mode 100644
index 000000000..3d20263fd
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/withReceipActions.js
@@ -0,0 +1,33 @@
+import { connect } from 'react-redux';
+import {
+ submitReceipt,
+ deleteReceipt,
+ fetchReceipt,
+ fetchReceiptsTable,
+ editReceipt,
+} from 'store/receipt/receipt.actions';
+import t from 'store/types';
+
+const mapDispatchToProps = (dispatch) => ({
+ requestSubmitReceipt: (form) => dispatch(submitReceipt({ form })),
+ requestFetchReceipt: (id) => dispatch(fetchReceipt({ id })),
+ requestEditReceipt: (id, form) => dispatch(editReceipt( id, form )),
+ requestDeleteReceipt: (id) => dispatch(deleteReceipt({ id })),
+ requestFetchReceiptsTable: (query = {}) =>
+ dispatch(fetchReceiptsTable({ query: { ...query } })),
+ // requestDeleteBulkReceipt: (ids) => dispatch(deleteBulkReceipt({ ids })),
+
+ changeReceiptView: (id) =>
+ dispatch({
+ type: t.RECEIPTS_SET_CURRENT_VIEW,
+ currentViewId: parseInt(id, 10),
+ }),
+
+ addReceiptsTableQueries: (queries) =>
+ dispatch({
+ type: t.RECEIPTS_TABLE_QUERIES_ADD,
+ queries,
+ }),
+});
+
+export default connect(null, mapDispatchToProps);
diff --git a/client/src/containers/Sales/Receipt/withReceiptDetail.js b/client/src/containers/Sales/Receipt/withReceiptDetail.js
new file mode 100644
index 000000000..a2f19dca9
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/withReceiptDetail.js
@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import { getReceiptByIdFactory } from 'store/receipt/receipt.selector';
+
+export default () => {
+ const getReceiptById = getReceiptByIdFactory();
+
+ const mapStateToProps = (state, props) => ({
+ receipt: getReceiptById(state, props),
+ });
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Sales/Receipt/withReceipts.js b/client/src/containers/Sales/Receipt/withReceipts.js
new file mode 100644
index 000000000..7881125be
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/withReceipts.js
@@ -0,0 +1,29 @@
+import { connect } from 'react-redux';
+import { getResourceViews } from 'store/customViews/customViews.selectors';
+import {
+ getReceiptCurrentPageFactory,
+ getReceiptsTableQueryFactory,
+ getReceiptsPaginationMetaFactory,
+} from 'store/receipt/receipt.selector';
+
+export default (mapState) => {
+ const getReceiptsItems = getReceiptCurrentPageFactory();
+ const getReceiptPaginationMeta = getReceiptsPaginationMetaFactory();
+ const getReceiptsTableQuery = getReceiptsTableQueryFactory();
+
+ const mapStateToProps = (state, props) => {
+ const tableQuery = getReceiptsTableQuery(state, props);
+
+ const mapped = {
+ receiptsCurrentPage: getReceiptsItems(state, props, tableQuery),
+ receiptview:getResourceViews(state, props, 'sales_receipts'),
+ receiptItems: state.sales_receipts.items,
+ receiptTableQuery: tableQuery,
+ receiptsPagination: getReceiptPaginationMeta(state, props, tableQuery),
+ receiptsLoading: state.sales_receipts.loading,
+ };
+
+ return mapState ? mapState(mapped, state, props) : mapped;
+ };
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Vendors/withVendorActions.js b/client/src/containers/Vendors/withVendorActions.js
new file mode 100644
index 000000000..c387cd400
--- /dev/null
+++ b/client/src/containers/Vendors/withVendorActions.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import {
+ submitVendor,
+ editVendor,
+ deleteVendor,
+ fetchVendorsTable,
+} from 'store/vendors/vendors.actions';
+import t from 'store/types';
+
+
+const mapDipatchToProps = (dispatch) => ({
+ requestSubmitVendor: (form) => dispatch(submitVendor({ form })),
+ requestEditVendor: (id, form) => dispatch(editVendor(id, form)),
+ requestFetchVendorsTable: (query = {}) =>
+ dispatch(fetchVendorsTable({ query: { ...query } })),
+ requestDeleteEstimate: (id) => dispatch(deleteVendor({ id })),
+
+ changeVendorView: (id) =>
+ dispatch({
+ type: t.VENDORS_SET_CURRENT_VIEW,
+ currentViewId: parseInt(id, 10),
+ }),
+
+ addVendorsTableQueries: (queries) =>
+ dispatch({
+ type: t.VENDORS_TABLE_QUERIES_ADD,
+ queries,
+ }),
+});
+
+export default connect(null, mapDipatchToProps);
+
diff --git a/client/src/containers/Vendors/withVendors.js b/client/src/containers/Vendors/withVendors.js
new file mode 100644
index 000000000..88e483be0
--- /dev/null
+++ b/client/src/containers/Vendors/withVendors.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import { getResourceViews } from 'store/customViews/customViews.selectors';
+
+import {
+ getVendorCurrentPageFactory,
+ getVendorsTableQuery,
+ getVendorsPaginationMetaFactory,
+} from 'store/vendors/vendors.selectors';
+
+export default (mapState) => {
+ const getVendorsItems = getVendorCurrentPageFactory();
+ const getVendorsPaginationMeta = getVendorsPaginationMetaFactory();
+ const mapStateToProps = (state, props) => {
+ const query = getVendorsTableQuery(state, props);
+
+ const mapped = {
+ vendorsCurrentPage: getVendorsItems(state, props, query),
+ vendorViews: getResourceViews(state, props, 'vendors'),
+ vendorItems: state.vendors.items,
+ vendorTableQuery: query,
+ vendorsPageination: getVendorsPaginationMeta(state, props, query),
+ vendorsLoading: state.vendors.loading,
+ };
+ return mapState ? mapState(mapped, state, props) : mapped;
+ };
+ return connect(mapStateToProps);
+};
diff --git a/client/src/lang/en/index.js b/client/src/lang/en/index.js
index b501e64b7..df8da8da8 100644
--- a/client/src/lang/en/index.js
+++ b/client/src/lang/en/index.js
@@ -458,6 +458,7 @@ export default {
display_name_: 'Display name',
new_customer: 'New Customer',
customer_type: 'Customer Type',
+ customer_account: 'Customer Account',
business: 'Business',
individual: 'Individual',
display_name: 'Display Name',
@@ -528,14 +529,20 @@ export default {
logic_expression: 'logic expression',
assign_to_customer: 'Assign to Customer',
inactive: 'Inactive',
- should_select_customers_with_entries_have_receivable_account: 'Should select customers with entries that have receivable account.',
- should_select_vendors_with_entries_have_payable_account: 'Should select vendors with entries that have payable account.',
- vendors_should_selected_with_payable_account_only: 'Vendors contacts should selected with payable account only.',
- customers_should_selected_with_receivable_account_only: 'Customers contacts should selected with receivable account only.',
+ should_select_customers_with_entries_have_receivable_account:
+ 'Should select customers with entries that have receivable account.',
+ should_select_vendors_with_entries_have_payable_account:
+ 'Should select vendors with entries that have payable account.',
+ vendors_should_selected_with_payable_account_only:
+ 'Vendors contacts should selected with payable account only.',
+ customers_should_selected_with_receivable_account_only:
+ 'Customers contacts should selected with receivable account only.',
amount_cannot_be_zero_or_empty: 'Amount cannot be zero or empty.',
- should_total_of_credit_and_debit_be_equal: 'Should total of credit and debit be equal.',
+ should_total_of_credit_and_debit_be_equal:
+ 'Should total of credit and debit be equal.',
no_accounts: 'No Accounts',
- the_accounts_have_been_successfully_inactivated: 'The accounts have been successfully inactivated.',
+ the_accounts_have_been_successfully_inactivated:
+ 'The accounts have been successfully inactivated.',
account_code_is_not_unique: 'Account code is not unqiue.',
are_sure_to_publish_this_expense:
'Are you sure you want to publish this expense?',
@@ -546,10 +553,121 @@ export default {
accounts_without_zero_balance: 'Accounts without zero-balance',
accounts_with_transactions: 'Accounts with transactions',
- include_accounts_once_has_transactions_on_given_date_period: 'Include accounts that onces have transactions on the given date period only.',
- include_accounts_and_exclude_zero_balance: 'Include accounts and exclude that ones have zero-balance.',
- all_accounts_including_with_zero_balance: 'All accounts, including that ones have zero-balance.',
+ include_accounts_once_has_transactions_on_given_date_period:
+ 'Include accounts that onces have transactions on the given date period only.',
+ include_accounts_and_exclude_zero_balance:
+ 'Include accounts and exclude that ones have zero-balance.',
+ all_accounts_including_with_zero_balance:
+ 'All accounts, including that ones have zero-balance.',
notifications: 'Notifications',
- you_could_not_delete_account_has_child_accounts: 'You could not delete account has child accounts.',
- journal_entry: 'Journal Entry'
+ you_could_not_delete_account_has_child_accounts:
+ 'You could not delete account has child accounts.',
+ journal_entry: 'Journal Entry',
+ estimate: 'Estimate #',
+ estimate_date: 'Estimate Date',
+ expiration_date: 'Expiration Date',
+ customer_note: 'Customer Note',
+ select_customer_account: 'Select Customer Account',
+ select_product: 'Select Product',
+ reference: 'Reference #',
+ clear: 'Clear',
+ save_send: 'Save & Send',
+ estimates: 'Estimates',
+ edit_estimate: 'Edit Estimate',
+ delete_estimate: 'Delete Estimate',
+ new_estimate: 'New Estimate',
+ customer_name_: 'Customer name',
+ estimate_date_: 'Estismate date',
+ expiration_date_: 'Expiration date',
+ estimate_number_: 'Estimate number',
+ discount: 'Discount %',
+ quantity: 'Quantity',
+ rate: 'Rate',
+ estimate_list: 'Estimate List',
+ estimate_number: 'Estimate Number',
+ product_and_service: 'Product/Service',
+ the_estimate_has_been_successfully_edited:
+ 'The estimate #{number} has been successfully edited.',
+ the_estimate_has_been_successfully_created:
+ 'The estimate #{number} has been successfully created.',
+ the_estimate_has_been_successfully_deleted:
+ 'The estimate has been successfully deleted.',
+ once_delete_this_estimate_you_will_able_to_restore_it: `Once you delete this estimate, you won\'t be able to restore it later. Are you sure you want to delete this estimate?`,
+ cannot_be_zero_or_empty: 'cannot be zero or empty.',
+ invocies: 'Invoices',
+ invoices_list: 'Invoices List',
+ invoice_date: 'Invoice Date',
+ due_date: 'Due Date',
+ invoice_date_: 'Invoice date',
+ invoice_no: 'Invoice #',
+ invoice_no__: 'Invoice No',
+ invoice_no_: 'Invoice number',
+ due_date_: 'Due date',
+ invoice_message: 'Invoice Message',
+ reference_no: 'Reference No',
+ edit_invoice: 'Edit Invoice',
+ delete_invoice: 'Delete Invoice',
+ new_invoice: 'New Invoice',
+ invoice_list: 'Invoice List',
+ the_invoice_has_been_successfully_edited:
+ 'The invoice #{number} has been successfully edited.',
+ the_invocie_has_been_successfully_created:
+ 'The invoice #{number} has been successfully created.',
+ the_invocie_has_been_successfully_deleted:
+ 'The invoice has been successfully deleted.',
+ once_delete_this_invoice_you_will_able_to_restore_it: `Once you delete this invoice, you won\'t be able to restore it later. Are you sure you want to delete this invoice?`,
+ receipt_list: 'Receipt List',
+ receipts: 'Receipts',
+ receipt: 'Receipt #',
+ receipt_date_: 'Receipt date',
+ receipt_date: 'Receipt Date',
+ deposit_account_: 'Deposit account',
+ receipt_message_: 'Receipt message',
+ receipt_no_: 'receipt number',
+ edit_receipt: 'Edit Receipt',
+ delete_receipt: 'Delete Receipt',
+ new_receipt: 'New Receipt',
+ receipt_message: 'Receipt Message',
+ statement: 'Statement',
+ deposit_account: 'Deposit Account',
+ send_to_email: 'Send to email',
+ select_deposit_account: 'Select Deposit Account',
+ once_delete_this_receipt_you_will_able_to_restore_it: `Once you delete this receipt, you won\'t be able to restore it later. Are you sure you want to delete this receipt?`,
+ the_receipt_has_been_successfully_created:
+ 'The recepit has been successfully created.',
+ the_receipt_has_been_successfully_edited:
+ 'The receipt has been successfully edited.',
+ the_receipt_has_been_successfully_deleted:
+ 'The receipt has been successfully deleted.',
+ bill_list: 'Bill List',
+ bills: 'Bills',
+ accept: 'Accept',
+ vendor_name: 'Vendor Name',
+ select_vendor_account: 'Select Vendor Account',
+ select_accept_account: 'Select Accept Account',
+ bill_date: 'Bill Date',
+ due_date: 'Due Date',
+ bill_number: 'Bill Number',
+ edit_bill: 'Edit Bill',
+ new_bill: 'New Bill',
+ bill_date_: 'Bill date',
+ bill_number_: 'Bill number',
+ vendor_name_: 'Vendor name',
+ delete_bill: 'Delete Bill',
+ the_bill_has_been_successfully_edited:
+ 'The bill #{number} has been successfully edited.',
+ the_bill_has_been_successfully_created:
+ 'The bill has been successfully created.',
+ the_bill_has_been_successfully_deleted:
+ 'The bill has been successfully deleted.',
+ once_delete_this_bill_you_will_able_to_restore_it: `Once you delete this bill, you won\'t be able to restore it later. Are you sure you want to delete this bill?`,
+ edit_payment_receive: 'Edit Payment Receive',
+ new_payment_receive: 'New Payment Receive',
+ payment_receives: 'Payment Receives',
+ payment_receive_no: 'Payment Receive #',
+ payment_receive_no_: 'Payment receive no',
+ the_payment_receive_has_been_successfully_created:
+ 'The payment receive has been successfully created.',
+ select_invoice: 'Select Invoice',
+ payment_mades: 'Payment Mades',
};
diff --git a/client/src/routes/dashboard.js b/client/src/routes/dashboard.js
index dc4ec2e2b..90eacfa50 100644
--- a/client/src/routes/dashboard.js
+++ b/client/src/routes/dashboard.js
@@ -204,4 +204,122 @@ export default [
}),
breadcrumb: 'Customers',
},
+
+ //Estimates
+ {
+ path: `/estimates/:id/edit`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Estimate/Estimates'),
+ }),
+ breadcrumb: 'Edit',
+ },
+ {
+ path: `/estimates/new`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Estimate/Estimates'),
+ }),
+ breadcrumb: 'New Estimates',
+ },
+ {
+ path: `/estimates`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Estimate/EstimateList'),
+ }),
+ breadcrumb: 'Estimates List',
+ },
+
+ //Invoices
+
+ {
+ path: `/invoices/:id/edit`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Invoice/Invoices'),
+ }),
+ breadcrumb: 'Edit',
+ },
+
+ {
+ path: `/invoices/new`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Invoice/Invoices'),
+ }),
+ breadcrumb: 'New Invoice',
+ },
+ {
+ path: `/invoices`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Invoice/InvoiceList'),
+ }),
+ breadcrumb: 'Invoices List',
+ },
+
+ //Receipts
+ {
+ path: `/receipts/:id/edit`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Receipt/Receipts'),
+ }),
+ breadcrumb: 'Edit',
+ },
+ {
+ path: `/receipts/new`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Receipt/Receipts'),
+ }),
+ breadcrumb: 'New Receipt',
+ },
+ {
+ path: `/receipts`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Receipt/ReceiptList'),
+ }),
+ breadcrumb: 'Receipt List',
+ },
+
+ // Payment Receives
+
+ {
+ path: `/payment-receive/:id/edit`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/PaymentReceive/PaymentReceives'),
+ }),
+ breadcrumb: 'Edit',
+ },
+ {
+ path: `/payment-receive/new`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/PaymentReceive/PaymentReceives'),
+ }),
+ breadcrumb: 'New Payment Receive',
+ },
+
+ //Bills
+ {
+ path: `/bills/:id/edit`,
+ component: LazyLoader({
+ loader: () => import('containers/Purchases/Bill/Bills'),
+ }),
+ breadcrumb: 'Edit',
+ },
+ {
+ path: `/bills/new`,
+ component: LazyLoader({
+ loader: () => import('containers/Purchases/Bill/Bills'),
+ }),
+ breadcrumb: 'New Bill',
+ },
+ {
+ path: `/bills`,
+ component: LazyLoader({
+ loader: () => import('containers/Purchases/Bill/BillList'),
+ }),
+ breadcrumb: 'Bill List',
+ },
+ {
+ path: `/receipts`,
+ component: LazyLoader({
+ loader: () => import('containers/Sales/Receipt/ReceiptList'),
+ }),
+ breadcrumb: 'Receipt List',
+ },
];
diff --git a/client/src/store/Bills/bills.actions.js b/client/src/store/Bills/bills.actions.js
new file mode 100644
index 000000000..8957f182c
--- /dev/null
+++ b/client/src/store/Bills/bills.actions.js
@@ -0,0 +1,95 @@
+import ApiService from 'services/ApiService';
+import t from 'store/types';
+
+export const fetchBillsTable = ({ query = {} }) => {
+ return (dispatch, getState) =>
+ new Promise((resolve, rejcet) => {
+ const pageQuery = getState().bills.tableQuery;
+
+ dispatch({
+ type: t.BILLS_TABLE_LOADING,
+ payload: {
+ loading: true,
+ },
+ });
+ ApiService.get('purchases/bills', {
+ params: { ...pageQuery, ...query },
+ })
+ .then((response) => {
+ dispatch({
+ type: t.BILLS_PAGE_SET,
+ payload: {
+ bills: response.data.bills.results,
+ pagination: response.data.bills.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.BILLS_ITEMS_SET,
+ payload: {
+ bills: response.data.bills.results,
+ },
+ });
+ dispatch({
+ type: t.BILLS_PAGINATION_SET,
+ payload: {
+ pagination: response.data.bills.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.BILLS_TABLE_LOADING,
+ payload: {
+ loading: false,
+ },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ rejcet(error);
+ });
+ });
+};
+
+export const deleteBill = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.delete(`purchases/bills/${id}`)
+ .then((response) => {
+ dispatch({ type: t.BILL_DELETE, payload: { id } });
+ resovle(response);
+ })
+ .catch((error) => {
+ reject(error.response.data.errors || []);
+ });
+ });
+};
+
+export const submitBill = ({ form }) => {
+ return (dispatch) => ApiService.post('purchases/bills', form);
+};
+
+export const fetchBill = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.get(`purchases/bills/${id}`)
+ .then((response) => {
+ const { bill } = response.data;
+
+ dispatch({
+ type: t.BILL_SET,
+ payload: { id, bill },
+ });
+ resovle(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ reject(data?.errors);
+ });
+ });
+};
+
+export const editBill = (id, form) => {
+ return (dispatch) => ApiService.post(`purchases/bills/${id}`, form);
+};
diff --git a/client/src/store/Bills/bills.reducer.js b/client/src/store/Bills/bills.reducer.js
new file mode 100644
index 000000000..bff11f84b
--- /dev/null
+++ b/client/src/store/Bills/bills.reducer.js
@@ -0,0 +1,104 @@
+import { createReducer } from '@reduxjs/toolkit';
+import { createTableQueryReducers } from 'store/queryReducers';
+
+import t from 'store/types';
+
+const initialState = {
+ items: {},
+ views: {},
+ loading: false,
+ currentViewId: -1,
+ tableQuery: {
+ page_size: 5,
+ page: 1,
+ },
+};
+
+const defaultBill = {
+ entries: [],
+};
+
+const reducer = createReducer(initialState, {
+ [t.BILL_SET]: (state, action) => {
+ const { id, bill } = action.payload;
+ const _bill = state.items[id] || {};
+
+ state.items[id] = { ...defaultBill, ..._bill, ...bill };
+ },
+
+ [t.BILLS_TABLE_LOADING]: (state, action) => {
+ const { loading } = action.payload;
+ state.loading = loading;
+ },
+
+ [t.BILLS_SET_CURRENT_VIEW]: (state, action) => {
+ state.currentViewId = action.currentViewId;
+ },
+
+ [t.BILLS_ITEMS_SET]: (state, action) => {
+ const { bills } = action.payload;
+ const _bills = {};
+
+ bills.forEach((bill) => {
+ const oldBill = state.items[bill.id] || {};
+
+ _bills[bill.id] = {
+ ...defaultBill,
+ ...oldBill,
+ ...bill,
+ };
+ });
+ state.items = {
+ ...state.items,
+ ..._bills,
+ };
+ },
+
+ [t.BILL_DELETE]: (state, action) => {
+ const { id } = action.payload;
+
+ if (typeof state.items[id] !== 'undefined') {
+ delete state.items[id];
+ }
+ },
+
+ [t.BILLS_PAGE_SET]: (state, action) => {
+ const { customViewId, bills, pagination } = action.payload;
+ const viewId = customViewId || -1;
+ const view = state.views[viewId] || {};
+
+ state.views[viewId] = {
+ ...view,
+ pages: {
+ ...(state.views?.[viewId]?.pages || {}),
+ [pagination.page]: {
+ ids: bills.map((i) => i.id),
+ },
+ },
+ };
+ },
+
+ [t.BILLS_PAGINATION_SET]: (state, action) => {
+ const { pagination, customViewId } = action.payload;
+
+ const mapped = {
+ pageSize: parseInt(pagination.pageSize, 10),
+ page: parseInt(pagination.page, 10),
+ total: parseInt(pagination.total, 10),
+ };
+ const paginationMeta = {
+ ...mapped,
+ pagesCount: Math.ceil(mapped.total / mapped.pageSize),
+ pageIndex: Math.max(mapped.page - 1, 0),
+ };
+ state.views = {
+ ...state.views,
+ [customViewId]: {
+ ...(state.views?.[customViewId] || {}),
+ paginationMeta,
+ },
+ };
+ },
+});
+
+export default createTableQueryReducers('bills', reducer);
diff --git a/client/src/store/Bills/bills.selectors.js b/client/src/store/Bills/bills.selectors.js
new file mode 100644
index 000000000..633690ee3
--- /dev/null
+++ b/client/src/store/Bills/bills.selectors.js
@@ -0,0 +1,53 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { pickItemsFromIds, paginationLocationQuery } from 'store/selectors';
+
+const billTableQuery = (state) => state.bills.tableQuery;
+
+const billPageSelector = (state, props, query) => {
+ const viewId = state.bills.currentViewId;
+ return state.bills.views?.[viewId]?.pages?.[query.page];
+};
+const billItemsSelector = (state) => state.bills.items;
+
+const billByIdSelector = (state, props) => state.bills.items[props.billId];
+
+const billPaginationSelector = (state, props) => {
+ const viewId = state.bills.currentViewId;
+ return state.bills.views?.[viewId];
+};
+
+export const getBillTableQueryFactory = () =>
+ createSelector(
+ paginationLocationQuery,
+ billTableQuery,
+ (locationQuery, tableQuery) => {
+ return {
+ ...locationQuery,
+ ...tableQuery,
+ };
+ },
+ );
+
+/**
+ * Get current page bills items.
+ * @return {Array}
+ */
+export const getBillCurrentPageFactory = () =>
+ createSelector(billPageSelector, billItemsSelector, (billPage, billItems) => {
+ return typeof billPage === 'object'
+ ? pickItemsFromIds(billItems, billPage.ids) || []
+ : [];
+ });
+
+/**
+ * Retrieve bill details of the given bill id.
+ */
+export const getBillByIdFactory = () =>
+ createSelector(billByIdSelector, (bill) => {
+ return bill;
+ });
+
+export const getBillPaginationMetaFactory = () =>
+ createSelector(billPaginationSelector, (billPage) => {
+ return billPage?.paginationMeta || {};
+ });
diff --git a/client/src/store/Bills/bills.type.js b/client/src/store/Bills/bills.type.js
new file mode 100644
index 000000000..3ce6797d2
--- /dev/null
+++ b/client/src/store/Bills/bills.type.js
@@ -0,0 +1,12 @@
+export default {
+ BILL_DELETE: 'BILL_DELETE',
+ BILLS_BULK_DELETE: 'BILLS_BULK_DELETE',
+ BILLS_LIST_SET: 'BILLS_LIST_SET',
+ BILL_SET: 'BILL_SET',
+ BILLS_SET_CURRENT_VIEW: 'BILLS_SET_CURRENT_VIEW',
+ BILLS_TABLE_QUERIES_ADD: 'BILLS_TABLE_QUERIES_ADD',
+ BILLS_TABLE_LOADING: 'BILLS_TABLE_LOADING',
+ BILLS_PAGINATION_SET: 'BILLS_PAGINATION_SET',
+ BILLS_PAGE_SET: 'BILLS_PAGE_SET',
+ BILLS_ITEMS_SET: 'BILLS_ITEMS_SET',
+};
diff --git a/client/src/store/Estimate/estimates.actions.js b/client/src/store/Estimate/estimates.actions.js
new file mode 100644
index 000000000..4c07d0baa
--- /dev/null
+++ b/client/src/store/Estimate/estimates.actions.js
@@ -0,0 +1,140 @@
+import ApiService from 'services/ApiService';
+import t from 'store/types';
+
+export const submitEstimate = ({ form }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.post('sales/estimates', form)
+ .then((response) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ reject(data?.errors);
+ });
+ });
+};
+
+export const editEstimate = (id, form) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.post(`sales/estimates/${id}`, form)
+ .then((response) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ reject(data?.errors);
+ });
+ });
+};
+
+export const deleteEstimate = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.delete(`sales/estimates/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.ESTIMATE_DELETE,
+ payload: { id },
+ });
+ resovle(response);
+ })
+ .catch((error) => {
+ reject(error.response.data.errors || []);
+ });
+ });
+};
+
+export const fetchEstimate = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.get(`sales/estimates/${id}`)
+ .then((response) => {
+ const { estimate } = response.data;
+ dispatch({
+ type: t.ESTIMATE_SET,
+ payload: {
+ id,
+ estimate,
+ },
+ });
+ resovle(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ reject(data?.errors);
+ });
+ });
+};
+
+export const fetchEstimatesTable = ({ query = {} }) => {
+ return (dispatch, getState) =>
+ new Promise((resolve, rejcet) => {
+ const pageQuery = getState().sales_estimates.tableQuery;
+ dispatch({
+ type: t.ESTIMATES_TABLE_LOADING,
+ payload: {
+ loading: true,
+ },
+ });
+ ApiService.get('sales/estimates', {
+ params: { ...pageQuery, ...query },
+ })
+ .then((response) => {
+ dispatch({
+ type: t.ESTIMATES_PAGE_SET,
+ payload: {
+ sales_estimates: response.data.sales_estimates.results,
+ pagination: response.data.sales_estimates.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.ESTIMATES_ITEMS_SET,
+ payload: {
+ sales_estimates: response.data.sales_estimates.results,
+ },
+ });
+ dispatch({
+ type: t.ESTIMATES_PAGINATION_SET,
+ payload: {
+ pagination: response.data.sales_estimates.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.ESTIMATES_TABLE_LOADING,
+ payload: {
+ loading: false,
+ },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ rejcet(error);
+ });
+ });
+};
diff --git a/client/src/store/Estimate/estimates.reducer.js b/client/src/store/Estimate/estimates.reducer.js
new file mode 100644
index 000000000..55b6604f3
--- /dev/null
+++ b/client/src/store/Estimate/estimates.reducer.js
@@ -0,0 +1,107 @@
+import { createReducer } from '@reduxjs/toolkit';
+import { createTableQueryReducers } from 'store/queryReducers';
+
+import t from 'store/types';
+
+const initialState = {
+ items: {},
+ views: {},
+ loading: false,
+ tableQuery: {
+ page_size: 5,
+ page: 1,
+ },
+ currentViewId: -1,
+};
+
+const defaultEstimate = {
+ entries: [],
+};
+
+const reducer = createReducer(initialState, {
+ [t.ESTIMATE_SET]: (state, action) => {
+ const { id, estimate } = action.payload;
+ const _estimate = state.items[id] || {};
+
+ state.items[id] = { ...defaultEstimate, ..._estimate, ...estimate };
+ },
+
+ [t.ESTIMATES_ITEMS_SET]: (state, action) => {
+ const { sales_estimates } = action.payload;
+ const _estimates = {};
+ sales_estimates.forEach((estimate) => {
+ _estimates[estimate.id] = {
+ ...defaultEstimate,
+ ...estimate,
+ };
+ });
+ state.items = {
+ ...state.items,
+ ..._estimates,
+ };
+ },
+
+ [t.ESTIMATES_TABLE_LOADING]: (state, action) => {
+ const { loading } = action.payload;
+ state.loading = loading;
+ },
+
+ [t.ESTIMATES_SET_CURRENT_VIEW]: (state, action) => {
+ state.currentViewId = action.currentViewId;
+ },
+
+ [t.ESTIMATE_DELETE]: (state, action) => {
+ const { id } = action.payload;
+
+ if (typeof state.items[id] !== 'undefined') {
+ delete state.items[id];
+ }
+ },
+
+ [t.ESTIMATES_PAGE_SET]: (state, action) => {
+ // @todo camelCase keys.
+ const { customViewId, sales_estimates, pagination } = action.payload;
+
+ const viewId = customViewId || -1;
+ const view = state.views[viewId] || {};
+
+ state.views[viewId] = {
+ ...view,
+ pages: {
+ ...(state.views?.[viewId]?.pages || {}),
+ [pagination.page]: {
+ ids: sales_estimates.map((i) => i.id),
+ },
+ },
+ };
+ },
+
+ [t.ESTIMATES_PAGINATION_SET]: (state, action) => {
+ const { pagination, customViewId } = action.payload;
+
+ const mapped = {
+ pageSize: parseInt(pagination.pageSize, 10),
+ page: parseInt(pagination.page, 10),
+ total: parseInt(pagination.total, 10),
+ };
+ const paginationMeta = {
+ ...mapped,
+ pagesCount: Math.ceil(mapped.total / mapped.pageSize),
+ pageIndex: Math.max(mapped.page - 1, 0),
+ };
+
+ state.views = {
+ ...state.views,
+ [customViewId]: {
+ ...(state.views?.[customViewId] || {}),
+ paginationMeta,
+ },
+ };
+ },
+});
+
+export default createTableQueryReducers('sales_estimates', reducer);
+
+export const getEstimateById = (state, id) => {
+ return state.sales_estimates.items[id];
+};
diff --git a/client/src/store/Estimate/estimates.selectors.js b/client/src/store/Estimate/estimates.selectors.js
new file mode 100644
index 000000000..86208cf16
--- /dev/null
+++ b/client/src/store/Estimate/estimates.selectors.js
@@ -0,0 +1,51 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { pickItemsFromIds, paginationLocationQuery } from 'store/selectors';
+
+const estimateTableQuery = (state) => state.sales_estimates.tableQuery;
+
+const estimateByIdSelector = (state, props) =>
+ state.sales_estimates.items[props.estimateId];
+
+const estimatesCurrentViewSelector = (state, props) => {
+ const viewId = state.sales_estimates.currentViewId;
+ return state.sales_estimates.views?.[viewId];
+};
+const estimateItemsSelector = (state) => state.sales_estimates.items;
+
+const estimatesPageSelector = (state, props, query) => {
+ const viewId = state.sales_estimates.currentViewId;
+ return state.sales_estimates.views?.[viewId]?.pages?.[query.page];
+};
+
+export const getEstimatesTableQueryFactory = () =>
+ createSelector(
+ paginationLocationQuery,
+ estimateTableQuery,
+ (locationQuery, tableQuery) => {
+ return {
+ ...locationQuery,
+ ...tableQuery,
+ };
+ },
+ );
+
+export const getEstimateCurrentPageFactory = () =>
+ createSelector(
+ estimatesPageSelector,
+ estimateItemsSelector,
+ (estimatePage, estimateItems) => {
+ return typeof estimatePage === 'object'
+ ? pickItemsFromIds(estimateItems, estimatePage.ids) || []
+ : [];
+ },
+ );
+
+export const getEstimateByIdFactory = () =>
+ createSelector(estimateByIdSelector, (estimate) => {
+ return estimate;
+ });
+
+export const getEstimatesPaginationMetaFactory = () =>
+ createSelector(estimatesCurrentViewSelector, (estimateView) => {
+ return estimateView?.paginationMeta || {};
+ });
diff --git a/client/src/store/Estimate/estimates.types.js b/client/src/store/Estimate/estimates.types.js
new file mode 100644
index 000000000..5eb1bb7a1
--- /dev/null
+++ b/client/src/store/Estimate/estimates.types.js
@@ -0,0 +1,13 @@
+export default {
+ ESTIMATES_LIST_SET: 'ESTIMATES_LIST_SET',
+ ESTIMATE_SET: 'ESTIMATE_SET',
+ ESTIMATE_DELETE: 'ESTIMATE_DELETE',
+ ESTIMATES_BULK_DELETE: 'ESTIMATES_BULK_DELETE',
+ ESTIMATES_SET_CURRENT_VIEW: 'ESTIMATES_SET_CURRENT_VIEW',
+ ESTIMATES_TABLE_QUERIES_ADD: 'ESTIMATES_TABLE_QUERIES_ADD',
+ ESTIMATES_TABLE_LOADING: 'ESTIMATES_TABLE_LOADING',
+ ESTIMATES_PAGINATION_SET: 'ESTIMATES_PAGINATION_SET',
+ ESTIMATES_PAGE_SET: 'ESTIMATES_PAGE_SET',
+ ESTIMATES_ITEMS_SET:'ESTIMATES_ITEMS_SET',
+
+};
diff --git a/client/src/store/Invoice/invoices.actions.js b/client/src/store/Invoice/invoices.actions.js
new file mode 100644
index 000000000..e9d26362d
--- /dev/null
+++ b/client/src/store/Invoice/invoices.actions.js
@@ -0,0 +1,145 @@
+import ApiService from 'services/ApiService';
+import t from 'store/types';
+
+export const submitInvoice = ({ form }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ // @todo remove dead-code
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.post('sales/invoices', form)
+ .then((response) => {
+ // @todo remove dead-code
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ // @todo remove dead-code
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+
+ reject(data?.errors);
+ });
+ });
+};
+
+export const deleteInvoice = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.delete(`sales/invoices/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.INVOICE_DELETE,
+ payload: { id },
+ });
+ resovle(response);
+ })
+ .catch((error) => {
+ reject(error.response.data.errors || []);
+ });
+ });
+};
+
+export const editInvoice = (id, form) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.post(`sales/invoices/${id}`, form)
+ .then((response) => {
+ // @todo remove dead-code
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+
+ // @todo remove dead-code
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ reject(data?.errors);
+ });
+ });
+};
+
+export const fetchInvoicesTable = ({ query } = {}) => {
+ return (dispatch, getState) =>
+ new Promise((resolve, reject) => {
+ const pageQuery = getState().sales_invoices.tableQuery;
+ dispatch({
+ type: t.INVOICES_TABLE_LOADING,
+ payload: {
+ loading: true,
+ },
+ });
+ ApiService.get('sales/invoices', {
+ params: { ...pageQuery, ...query },
+ })
+ .then((response) => {
+ dispatch({
+ type: t.INVOICES_PAGE_SET,
+ payload: {
+ sales_invoices: response.data.sales_invoices.results,
+ pagination: response.data.sales_invoices.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.INVOICES_ITEMS_SET,
+ payload: {
+ sales_invoices: response.data.sales_invoices.results,
+ },
+ });
+ dispatch({
+ type: t.INVOICES_PAGINATION_SET,
+ payload: {
+ pagination: response.data.sales_invoices.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.INVOICES_TABLE_LOADING,
+ payload: {
+ loading: false,
+ },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ });
+};
+
+export const fetchInvoice = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.get(`sales/invoices/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.INVOICE_SET,
+ payload: {
+ id,
+ sale_invoice: response.data.sale_invoice,
+ },
+ });
+ resovle(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ reject(data?.errors);
+ });
+ });
+};
diff --git a/client/src/store/Invoice/invoices.reducer.js b/client/src/store/Invoice/invoices.reducer.js
new file mode 100644
index 000000000..e9bbeff35
--- /dev/null
+++ b/client/src/store/Invoice/invoices.reducer.js
@@ -0,0 +1,105 @@
+import { createReducer } from '@reduxjs/toolkit';
+import { createTableQueryReducers } from 'store/queryReducers';
+
+import t from 'store/types';
+
+const initialState = {
+ items: {},
+ views: {},
+ loading: false,
+ currentViewId: -1,
+ tableQuery: {
+ page_size: 5,
+ page: 1,
+ },
+};
+
+const defaultInvoice = {
+ entries: [],
+};
+
+const reducer = createReducer(initialState, {
+ [t.INVOICE_SET]: (state, action) => {
+ const { id, sale_invoice } = action.payload;
+ const _invoice = state.items[id] || {};
+
+ state.items[id] = { ...defaultInvoice, ..._invoice, ...sale_invoice };
+ },
+
+ [t.INVOICES_ITEMS_SET]: (state, action) => {
+ const { sales_invoices } = action.payload;
+ const _invoices = {};
+ sales_invoices.forEach((invoice) => {
+ _invoices[invoice.id] = {
+ ...defaultInvoice,
+ ...invoice,
+ };
+ });
+ state.items = {
+ ...state.items,
+ ..._invoices,
+ };
+ },
+
+ [t.INVOICES_TABLE_LOADING]: (state, action) => {
+ const { loading } = action.payload;
+ state.loading = loading;
+ },
+
+ [t.INVOICES_SET_CURRENT_VIEW]: (state, action) => {
+ state.currentViewId = action.currentViewId;
+ },
+
+ [t.INVOICE_DELETE]: (state, action) => {
+ const { id } = action.payload;
+
+ if (typeof state.items[id] !== 'undefined') {
+ delete state.items[id];
+ }
+ },
+
+ [t.INVOICES_PAGE_SET]: (state, action) => {
+ const { customViewId, sales_invoices, pagination } = action.payload;
+
+ const viewId = customViewId || -1;
+ const view = state.views[viewId] || {};
+
+ state.views[viewId] = {
+ ...view,
+ pages: {
+ ...(state.views?.[viewId]?.pages || {}),
+ [pagination.page]: {
+ ids: sales_invoices.map((i) => i.id),
+ },
+ },
+ };
+ },
+
+ [t.INVOICES_PAGINATION_SET]: (state, action) => {
+ const { pagination, customViewId } = action.payload;
+
+ const mapped = {
+ pageSize: parseInt(pagination.pageSize, 10),
+ page: parseInt(pagination.page, 10),
+ total: parseInt(pagination.total, 10),
+ };
+ const paginationMeta = {
+ ...mapped,
+ pagesCount: Math.ceil(mapped.total / mapped.pageSize),
+ pageIndex: Math.max(mapped.page - 1, 0),
+ };
+ state.views = {
+ ...state.views,
+ [customViewId]: {
+ ...(state.views?.[customViewId] || {}),
+ paginationMeta,
+ },
+ };
+ },
+});
+
+export default createTableQueryReducers('sales_invoices', reducer);
+
+export const getInvoiceById = (state, id) => {
+ return state.sales_invoices.items[id];
+};
diff --git a/client/src/store/Invoice/invoices.selector.js b/client/src/store/Invoice/invoices.selector.js
new file mode 100644
index 000000000..bdc2bce0e
--- /dev/null
+++ b/client/src/store/Invoice/invoices.selector.js
@@ -0,0 +1,53 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { pickItemsFromIds, paginationLocationQuery } from 'store/selectors';
+
+const invoiceTableQuery = (state) => state.sales_invoices.tableQuery;
+
+const invoicesByIdSelector = (state, props) =>
+ state.sales_invoices.items[props.invoiceId];
+
+const invoicesPaginationSelector = (state, props) => {
+ const viewId = state.sales_invoices.currentViewId;
+ return state.sales_invoices.views?.[viewId];
+};
+
+const invoicesPageSelector = (state, props, query) => {
+ const viewId = state.sales_invoices.currentViewId;
+ return state.sales_invoices.views?.[viewId]?.pages?.[query.page];
+};
+
+const invoicesItemsSelector = (state) => state.sales_invoices.items;
+
+
+export const getInvoiceTableQueryFactory = () =>
+ createSelector(
+ paginationLocationQuery,
+ invoiceTableQuery,
+ (locationQuery, tableQuery) => {
+ return {
+ ...locationQuery,
+ ...tableQuery,
+ };
+ },
+);
+
+export const getInvoiceCurrentPageFactory = () =>
+ createSelector(
+ invoicesPageSelector,
+ invoicesItemsSelector,
+ (invoicePage, invoicesItems) => {
+ return typeof invoicePage === 'object'
+ ? pickItemsFromIds(invoicesItems, invoicePage.ids) || []
+ : [];
+ },
+ );
+
+export const getInvoiecsByIdFactory = () =>
+ createSelector(invoicesByIdSelector, (invoice) => {
+ return invoice;
+ });
+
+export const getInvoicePaginationMetaFactory = () =>
+ createSelector(invoicesPaginationSelector, (invoicePage) => {
+ return invoicePage?.paginationMeta || {};
+ });
diff --git a/client/src/store/Invoice/invoices.types.js b/client/src/store/Invoice/invoices.types.js
new file mode 100644
index 000000000..99d888867
--- /dev/null
+++ b/client/src/store/Invoice/invoices.types.js
@@ -0,0 +1,12 @@
+export default {
+ INVOICE_DELETE: 'INVOICE_DELETE',
+ INVOICES_BULK_DELETE: 'INVOICES_BULK_DELETE',
+ INVOICES_LIST_SET: 'INVOICES_LIST_SET',
+ INVOICE_SET: 'INVOICE_SET',
+ INVOICES_SET_CURRENT_VIEW: 'INVOICES_SET_CURRENT_VIEW',
+ INVOICES_TABLE_QUERIES_ADD: 'INVOICES_TABLE_QUERIES_ADD',
+ INVOICES_TABLE_LOADING: 'INVOICES_TABLE_LOADING',
+ INVOICES_PAGINATION_SET: 'INVOICES_PAGINATION_SET',
+ INVOICES_PAGE_SET: 'INVOICES_PAGE_SET',
+ INVOICES_ITEMS_SET: 'INVOICES_ITEMS_SET',
+};
diff --git a/client/src/store/PaymentReceive/paymentReceive.actions.js b/client/src/store/PaymentReceive/paymentReceive.actions.js
new file mode 100644
index 000000000..43d00f9cb
--- /dev/null
+++ b/client/src/store/PaymentReceive/paymentReceive.actions.js
@@ -0,0 +1,136 @@
+import ApiService from 'services/ApiService';
+import t from 'store/types';
+
+export const submitPaymentReceive = ({ form }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.post('sales/payment_receives', form)
+ .then((response) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ reject(data?.errors);
+ });
+ });
+};
+
+export const editPaymentReceive = (id, form) => {
+ return (dispatch) =>
+ new Promise((resolve, rejcet) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.post(`sales/payment_receives/${id}`, form)
+ .then((response) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ rejcet(data?.errors);
+ });
+ });
+};
+
+export const deletePaymentReceive = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.delete(`payment_receives/${id}`)
+ .then((response) => {
+ dispatch({ type: t.PAYMENT_RECEIVE_DELETE });
+ resovle(response);
+ })
+ .catch((error) => {
+ reject(error.response.data.errors || []);
+ });
+ });
+};
+
+export const fetchPaymentReceive = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.get(`payment_receives/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.PAYMENT_RECEIVE_SET,
+ payload: {
+ id,
+ payment_receive: response.data.payment_receive,
+ },
+ });
+ resovle(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ reject(data?.errors);
+ });
+ });
+};
+
+export const fetchPaymentReceivesTable = ({ query = {} }) => {
+ return (dispatch, getState) =>
+ new Promise((resolve, rejcet) => {
+ const pageQuery = getState().payment_receive.tableQuery;
+
+ dispatch({
+ type: t.PAYMENT_RECEIVES_TABLE_LOADING,
+ payload: {
+ loading: true,
+ },
+ });
+ ApiService.get('payment_receives', {
+ params: { ...pageQuery, ...query },
+ })
+ .then((response) => {
+ dispatch({
+ type: t.RECEIPTS_PAGE_SET,
+ payload: {
+ payment_receives: response.data.payment_receives.results,
+ pagination: response.data.payment_receives.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.PAYMENT_RECEIVES_ITEMS_SET,
+ payload: {
+ payment_receives: response.data.payment_receives.results,
+ },
+ });
+ dispatch({
+ type: t.PAYMENT_RECEIVES_PAGINATION_SET,
+ payload: {
+ pagination: response.data.payment_receives.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.PAYMENT_RECEIVES_TABLE_LOADING,
+ payload: {
+ loading: false,
+ },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ rejcet(error);
+ });
+ });
+};
diff --git a/client/src/store/PaymentReceive/paymentReceive.reducer.js b/client/src/store/PaymentReceive/paymentReceive.reducer.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/store/PaymentReceive/paymentReceive.selector.js b/client/src/store/PaymentReceive/paymentReceive.selector.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/store/PaymentReceive/paymentReceive.type.js b/client/src/store/PaymentReceive/paymentReceive.type.js
new file mode 100644
index 000000000..3c08a544c
--- /dev/null
+++ b/client/src/store/PaymentReceive/paymentReceive.type.js
@@ -0,0 +1,11 @@
+export default {
+ PAYMENT_RECEIVE_LIST_SET: 'PAYMENT_RECEIVE_LIST_SET',
+ PAYMENT_RECEIVE_SET: 'PAYMENT_RECEIVE_SET',
+ PAYMENT_RECEIVE_DELETE: 'PAYMENT_RECEIVE_DELETE',
+ PAYMENT_RECEIVE_SET_CURRENT_VIEW: 'PAYMENT_RECEIVE_SET_CURRENT_VIEW',
+ PAYMENT_RECEIVE_TABLE_QUERIES_ADD: 'PAYMENT_RECEIVE_TABLE_QUERIES_ADD',
+ PAYMENT_RECEIVES_TABLE_LOADING: 'PAYMENT_RECEIVES_TABLE_LOADING',
+ PAYMENT_RECEIVES_PAGE_SET: 'PAYMENT_RECEIVES_PAGE_SET',
+ PAYMENT_RECEIVES_ITEMS_SET: 'PAYMENT_RECEIVES_ITEMS_SET',
+ PAYMENT_RECEIVES_PAGINATION_SET: 'PAYMENT_RECEIVES_PAGINATION_SET',
+};
diff --git a/client/src/store/receipt/receipt.actions.js b/client/src/store/receipt/receipt.actions.js
new file mode 100644
index 000000000..2865446ac
--- /dev/null
+++ b/client/src/store/receipt/receipt.actions.js
@@ -0,0 +1,138 @@
+import ApiService from 'services/ApiService';
+import t from 'store/types';
+
+export const submitReceipt = ({ form }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.post('sales/receipts', form)
+ .then((response) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ reject(data?.errors);
+ });
+ });
+};
+
+export const deleteReceipt = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.delete(`sales/receipts/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.RECEIPT_DELETE,
+ payload: { id },
+ });
+ resovle(response);
+ })
+ .catch((error) => {
+ reject(error.response.data.errors || []);
+ });
+ });
+};
+
+export const editReceipt = (id, form) => {
+ return (dispatch) =>
+ new Promise((resolve, rejcet) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.post(`sales/receipts/${id}`, form)
+ .then((response) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ rejcet(data?.errors);
+ });
+ });
+};
+
+export const fetchReceipt = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.get(`sales/receipts/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.RECEIPT_SET,
+ payload: {
+ id,
+ receipt: response.data.receipt,
+ },
+ });
+ resovle(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ reject(data?.errors);
+ });
+ });
+};
+
+export const fetchReceiptsTable = ({ query = {} }) => {
+ return (dispatch, getState) =>
+ new Promise((resolve, rejcet) => {
+ const pageQuery = getState().sales_receipts.tableQuery;
+ dispatch({
+ type: t.RECEIPTS_TABLE_LOADING,
+ payload: {
+ loading: true,
+ },
+ });
+ ApiService.get('sales/receipts', {
+ params: { ...pageQuery, ...query },
+ })
+ .then((response) => {
+ dispatch({
+ type: t.RECEIPTS_PAGE_SET,
+ payload: {
+ sales_receipts: response.data.sales_receipts.results,
+ pagination: response.data.sales_receipts.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.RECEIPTS_ITEMS_SET,
+ payload: {
+ sales_receipts: response.data.sales_receipts.results,
+ },
+ });
+ dispatch({
+ type: t.RECEIPTS_PAGINATION_SET,
+ payload: {
+ pagination: response.data.sales_receipts.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.RECEIPTS_TABLE_LOADING,
+ payload: {
+ loading: false,
+ },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ rejcet(error);
+ });
+ });
+};
diff --git a/client/src/store/receipt/receipt.reducer.js b/client/src/store/receipt/receipt.reducer.js
new file mode 100644
index 000000000..346859bdc
--- /dev/null
+++ b/client/src/store/receipt/receipt.reducer.js
@@ -0,0 +1,103 @@
+import { createReducer } from '@reduxjs/toolkit';
+import { createTableQueryReducers } from 'store/queryReducers';
+import t from 'store/types';
+
+const initialState = {
+ items: {},
+ views: {},
+ loading: false,
+ tableQuery: {
+ page_size: 5,
+ page: 1,
+ },
+ currentViewId: -1,
+};
+
+const defaultReceipt = {
+ entries: [],
+};
+
+const reducer = createReducer(initialState, {
+ [t.RECEIPT_SET]: (state, action) => {
+ const { id, receipt } = action.payload;
+ const _receipt = state.items[id] || {};
+ state.items[id] = { ...defaultReceipt, ..._receipt, ...receipt };
+ },
+
+ [t.RECEIPTS_ITEMS_SET]: (state, action) => {
+ const { sales_receipts } = action.payload;
+ const _receipts = {};
+ sales_receipts.forEach((receipt) => {
+ _receipts[receipt.id] = {
+ ...defaultReceipt,
+ ...receipt,
+ };
+ });
+ state.items = {
+ ...state.items,
+ ..._receipts,
+ };
+ },
+
+ [t.RECEIPTS_TABLE_LOADING]: (state, action) => {
+ const { loading } = action.payload;
+ state.loading = loading;
+ },
+
+ [t.RECEIPT_DELETE]: (state, action) => {
+ const { id } = action.payload;
+ if (typeof state.items[id] !== 'undefined') {
+ delete state.items[id];
+ }
+ },
+
+ [t.RECEIPTS_SET_CURRENT_VIEW]: (state, action) => {
+ state.currentViewId = action.currentViewId;
+ },
+
+ [t.RECEIPTS_PAGE_SET]: (state, action) => {
+ const { customViewId, sales_receipts, pagination } = action.payload;
+
+ const viewId = customViewId || -1;
+ const view = state.views[viewId] || {};
+
+ state.views[viewId] = {
+ ...view,
+ pages: {
+ ...(state.views?.[viewId]?.pages || {}),
+ [pagination.page]: {
+ ids: sales_receipts.map((i) => i.id),
+ },
+ },
+ };
+ },
+
+ [t.RECEIPTS_PAGINATION_SET]: (state, action) => {
+ const { pagination, customViewId } = action.payload;
+
+ const mapped = {
+ pageSize: parseInt(pagination.pageSize, 10),
+ page: parseInt(pagination.page, 10),
+ total: parseInt(pagination.total, 10),
+ };
+ const paginationMeta = {
+ ...mapped,
+ pagesCount: Math.ceil(mapped.total / mapped.pageSize),
+ pageIndex: Math.max(mapped.page - 1, 0),
+ };
+
+ state.views = {
+ ...state.views,
+ [customViewId]: {
+ ...(state.views?.[customViewId] || {}),
+ paginationMeta,
+ },
+ };
+ },
+});
+
+export default createTableQueryReducers('sales_receipts', reducer);
+
+export const getReceiptById = (state, id) => {
+ return state.receipts.items[id];
+};
diff --git a/client/src/store/receipt/receipt.selector.js b/client/src/store/receipt/receipt.selector.js
new file mode 100644
index 000000000..e3b9e53e4
--- /dev/null
+++ b/client/src/store/receipt/receipt.selector.js
@@ -0,0 +1,52 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { pickItemsFromIds, paginationLocationQuery } from 'store/selectors';
+
+const receiptsPageSelector = (state, props, query) => {
+ const viewId = state.sales_receipts.currentViewId;
+ return state.sales_receipts.views?.[viewId]?.pages?.[query.page];
+};
+
+const receiptsPaginationSelector = (state, props) => {
+ const viewId = state.sales_receipts.currentViewId;
+ return state.sales_receipts.views?.[viewId];
+};
+
+const receiptItemsSelector = (state) => state.sales_receipts.items;
+
+const receiptTableQuery = (state) => state.sales_receipts.tableQuery;
+
+const receiptByIdSelector = (state, props) => state.sales_receipts.items[props.receiptId];
+
+
+export const getReceiptCurrentPageFactory = () =>
+ createSelector(
+ receiptsPageSelector,
+ receiptItemsSelector,
+ (receiptPage, receiptItems) => {
+ return typeof receiptPage === 'object'
+ ? pickItemsFromIds(receiptItems, receiptPage.ids) || []
+ : [];
+ },
+ );
+
+export const getReceiptsTableQueryFactory = () =>
+ createSelector(
+ paginationLocationQuery,
+ receiptTableQuery,
+ (locationQuery, tableQuery) => {
+ return {
+ ...locationQuery,
+ ...tableQuery,
+ };
+ },
+ );
+
+export const getReceiptByIdFactory = () =>
+ createSelector(receiptByIdSelector, (receipt) => {
+ return receipt;
+ });
+
+export const getReceiptsPaginationMetaFactory = () =>
+ createSelector(receiptsPaginationSelector, (receiptPage) => {
+ return receiptPage?.paginationMeta || {};
+ });
diff --git a/client/src/store/receipt/receipt.type.js b/client/src/store/receipt/receipt.type.js
new file mode 100644
index 000000000..c50dc5a0d
--- /dev/null
+++ b/client/src/store/receipt/receipt.type.js
@@ -0,0 +1,12 @@
+export default {
+ RECEIPT_DELETE: 'RECEIPT_DELETE',
+ RECEIPTS_BULK_DELETE: 'RECEIPTS_BULK_DELETE',
+ RECEIPTS_LIST_SET: 'RECEIPTS_LIST_SET',
+ RECEIPT_SET: 'RECEIPT_SET',
+ RECEIPTS_SET_CURRENT_VIEW: 'RECEIPTS_SET_CURRENT_VIEW',
+ RECEIPTS_TABLE_QUERIES_ADD: 'RECEIPTS_TABLE_QUERIES_ADD',
+ RECEIPTS_TABLE_LOADING: 'RECEIPTS_TABLE_LOADING',
+ RECEIPTS_PAGINATION_SET: 'RECEIPTS_PAGINATION_SET',
+ RECEIPTS_PAGE_SET: 'RECEIPTS_PAGE_SET',
+ RECEIPTS_ITEMS_SET: 'RECEIPTS_ITEMS_SET',
+};
diff --git a/client/src/store/reducers.js b/client/src/store/reducers.js
index cd401f3db..a9684de02 100644
--- a/client/src/store/reducers.js
+++ b/client/src/store/reducers.js
@@ -18,6 +18,11 @@ import globalSearch from './search/search.reducer';
import exchangeRates from './ExchangeRate/exchange.reducer';
import globalErrors from './globalErrors/globalErrors.reducer';
import customers from './customers/customers.reducer';
+import sales_estimates from './Estimate/estimates.reducer';
+import sales_invoices from './Invoice/invoices.reducer';
+import sales_receipts from './receipt/receipt.reducer';
+import bills from './Bills/bills.reducer';
+import vendors from './vendors/vendors.reducer';
export default combineReducers({
authentication,
@@ -38,4 +43,11 @@ export default combineReducers({
exchangeRates,
globalErrors,
customers,
+
+ // @todo camelCase
+ sales_estimates,
+ sales_invoices,
+ sales_receipts,
+ bills,
+ vendors,
});
diff --git a/client/src/store/types.js b/client/src/store/types.js
index 25af78fbb..182dd8562 100644
--- a/client/src/store/types.js
+++ b/client/src/store/types.js
@@ -17,6 +17,12 @@ import search from './search/search.type';
import register from './registers/register.type';
import exchangeRate from './ExchangeRate/exchange.type';
import customer from './customers/customers.type';
+import estimates from './Estimate/estimates.types';
+import invoices from './Invoice/invoices.types';
+import receipts from './receipt/receipt.type';
+import bills from './Bills/bills.type';
+import paymentReceives from './PaymentReceive/paymentReceive.type';
+import vendors from './vendors/vendors.types';
export default {
...authentication,
@@ -38,4 +44,10 @@ export default {
...register,
...exchangeRate,
...customer,
+ ...estimates,
+ ...invoices,
+ ...receipts,
+ ...bills,
+ ...paymentReceives,
+ ...vendors,
};
diff --git a/client/src/store/vendors/vendors.actions.js b/client/src/store/vendors/vendors.actions.js
new file mode 100644
index 000000000..8d1744bc8
--- /dev/null
+++ b/client/src/store/vendors/vendors.actions.js
@@ -0,0 +1,120 @@
+import ApiService from 'services/ApiService';
+import t from 'store/types';
+
+export const fetchVendorsTable = ({ query }) => {
+ return (dispatch, getState) =>
+ new Promise((resolve, reject) => {
+ const pageQuery = getState().vendors.tableQuery;
+ dispatch({
+ type: t.VENDORS_TABLE_LOADING,
+ payload: { loading: true },
+ });
+ // @todo remove dead-code.
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.get(`vendors`, { params: { ...pageQuery, ...query } })
+ .then((response) => {
+ dispatch({
+ type: t.VENDORS_PAGE_SET,
+ payload: {
+ vendors: response.data.vendors.results,
+ pagination: response.data.vendors.pagination,
+ customViewId: response.data.customViewId,
+ },
+ });
+ dispatch({
+ type: t.VENDORS_ITEMS_SET,
+ payload: {
+ vendors: response.data.vendors.results,
+ },
+ });
+ dispatch({
+ type: t.VENDORS_PAGINATION_SET,
+ payload: {
+ pagination: response.data.vendors.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.VENDORS_TABLE_LOADING,
+ payload: { loading: false },
+ });
+ // @todo remove dead-lock.
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ });
+};
+
+export const editVendor = ({ form, id }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ // @todo remove dread-code.
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+
+ ApiService.post(`vendors/${id}`, form)
+ .then((response) => {
+ // @todo remove dread-code.
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ // @todo remove dread-code.
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ reject(data?.errors);
+ });
+ });
+};
+
+export const deleteVendor = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ ApiService.delete(`vendors/${id}`)
+ .then((response) => {
+ dispatch({ type: t.VENDOR_DELETE, payload: { id } });
+ resolve(response);
+ })
+ .catch((error) => {
+ reject(error.response.data.errors || []);
+ });
+ });
+};
+
+export const submitVendor = ({ form }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+
+ ApiService.post('vendors', form)
+ .then((response) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ reject(data?.errors);
+ });
+ });
+};
diff --git a/client/src/store/vendors/vendors.reducer.js b/client/src/store/vendors/vendors.reducer.js
new file mode 100644
index 000000000..8a3737910
--- /dev/null
+++ b/client/src/store/vendors/vendors.reducer.js
@@ -0,0 +1,96 @@
+import { createReducer } from '@reduxjs/toolkit';
+import { createTableQueryReducers } from 'store/queryReducers';
+
+import t from 'store/types';
+
+const initialState = {
+ items: {},
+ views: {},
+ loading: false,
+ tableQuery: {
+ page_size: 5,
+ page: 1,
+ },
+ currentViewId: -1,
+};
+
+const reducer = createReducer(initialState, {
+ [t.VENDORS_TABLE_LOADING]: (state, action) => {
+ const { loading } = action.payload;
+ state.loading = loading;
+ },
+
+ [t.VENDORS_ITEMS_SET]: (state, action) => {
+ const { vendors } = action.payload;
+ const _vendors = {};
+ vendors.forEach((vendor) => {
+ _vendors[vendor.id] = {
+ ...vendor,
+ };
+ });
+ state.items = {
+ ...state.items,
+ ..._vendors,
+ };
+ },
+
+ [t.VENDORS_PAGE_SET]: (state, action) => {
+ const { customViewId, vendors, pagination } = action.payload;
+
+ const viewId = customViewId || -1;
+ const view = state.views[viewId] || {};
+
+ state.views[viewId] = {
+ ...view,
+ pages: {
+ ...(state.views?.[viewId]?.pages || {}),
+ [pagination.page]: {
+ ids: vendors.map((i) => i.id),
+ },
+ },
+ };
+ },
+
+ [t.VENDOR_DELETE]: (state, action) => {
+ const { id } = action.payload;
+
+ if (typeof state.items[id] !== 'undefined') {
+ delete state.items[id];
+ }
+ },
+
+ [t.VENDORS_SET_CURRENT_VIEW]: (state, action) => {
+ state.currentViewId = action.currentViewId;
+ },
+
+ [t.VENDORS_PAGINATION_SET]: (state, action) => {
+ const { pagination, customViewId } = action.payload;
+
+ const mapped = {
+ pageSize: parseInt(pagination.pageSize, 10),
+ page: parseInt(pagination.page, 10),
+ total: parseInt(pagination.total, 10),
+ };
+ const paginationMeta = {
+ ...mapped,
+ pagesCount: Math.ceil(mapped.total / mapped.pageSize),
+ pageIndex: Math.max(mapped.page - 1, 0),
+ };
+
+ state.views = {
+ ...state.views,
+ [customViewId]: {
+ ...(state.views?.[customViewId] || {}),
+ paginationMeta,
+ },
+ };
+ },
+
+ // [t.VENDOR_SET]: (state, action) => {
+ // const { id, vendor } = action.payload;
+ // const _venders = state.items[id] || {};
+ // state.items[id] = { ..._venders, ...vendor };
+ // },
+});
+
+export default createTableQueryReducers('vendors', reducer);
diff --git a/client/src/store/vendors/vendors.selectors.js b/client/src/store/vendors/vendors.selectors.js
new file mode 100644
index 000000000..708338074
--- /dev/null
+++ b/client/src/store/vendors/vendors.selectors.js
@@ -0,0 +1,54 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { pickItemsFromIds, paginationLocationQuery } from 'store/selectors';
+
+const vendorsTableQuery = (state) => {
+ return state.vendors.tableQuery;
+};
+
+export const getVendorsTableQuery = createSelector(
+ paginationLocationQuery,
+ vendorsTableQuery,
+ (locationQuery, tableQuery) => {
+ return {
+ ...locationQuery,
+ ...tableQuery,
+ };
+ },
+);
+
+const vendorsPageSelector = (state, props, query) => {
+ const viewId = state.vendors.currentViewId;
+ return state.vendors.views?.[viewId]?.pages?.[query.page];
+};
+
+const vendorsItemsSelector = (state) => state.vendors.items;
+
+export const getVendorCurrentPageFactory = () =>
+ createSelector(
+ vendorsPageSelector,
+ vendorsItemsSelector,
+ (vendorPage, vendorItems) => {
+ return typeof vendorPage === 'object'
+ ? pickItemsFromIds(vendorItems, vendorPage.ids) || []
+ : [];
+ },
+ );
+
+const vendorsPaginationSelector = (state, props) => {
+ const viewId = state.vendors.currentViewId;
+ return state.vendors.views?.[viewId];
+};
+
+export const getVendorsPaginationMetaFactory = () =>
+ createSelector(vendorsPaginationSelector, (vendorPage) => {
+ return vendorPage?.paginationMeta || {};
+ });
+
+const vendorByIdSelector = (state, props) => {
+ return state.vendors.items[props.vendorId];
+};
+
+export const getEstimateByIdFactory = () =>
+ createSelector(vendorByIdSelector, (vendor) => {
+ return vendor;
+ });
diff --git a/client/src/store/vendors/vendors.types.js b/client/src/store/vendors/vendors.types.js
new file mode 100644
index 000000000..0ddced9ba
--- /dev/null
+++ b/client/src/store/vendors/vendors.types.js
@@ -0,0 +1,11 @@
+export default {
+ VENDORS_ITEMS_SET: 'VENDORS_ITEMS_SET',
+ VENDOR_SET: 'VENDOR_SET',
+ VENDORS_PAGE_SET: 'VENDORS_PAGE_SET',
+ VENDORS_TABLE_LOADING: 'VENDORS_TABLE_LOADING',
+ VENDORS_TABLE_QUERIES_ADD: 'VENDORS_TABLE_QUERIES_ADD',
+ VENDOR_DELETE: 'VENDOR_DELETE',
+ VENDORS_BULK_DELETE: 'VENDORS_BULK_DELETE',
+ VENDORS_PAGINATION_SET: 'VENDORS_PAGINATION_SET',
+ VENDORS_SET_CURRENT_VIEW: 'VENDORS_SET_CURRENT_VIEW',
+};
diff --git a/client/src/style/App.scss b/client/src/style/App.scss
index d6a8e44a7..ba6f77aaf 100644
--- a/client/src/style/App.scss
+++ b/client/src/style/App.scss
@@ -59,9 +59,10 @@ $pt-font-family: Noto Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
@import 'pages/invite-user.scss';
@import 'pages/exchange-rate.scss';
@import 'pages/customer.scss';
+@import 'pages/estimate.scss';
-// Views
-@import 'views/filter-dropdown';
+ // Views
+ @import 'views/filter-dropdown';
@import 'views/sidebar';
.App {
@@ -100,45 +101,42 @@ body.authentication {
box-shadow: none;
}
-.select-list--fill-popover{
-
+.select-list--fill-popover {
.bp3-transition-container,
- .bp3-popover{
+ .bp3-popover {
min-width: 100%;
}
}
-.select-list--fill-button{
-
+.select-list--fill-button {
.bp3-popover-wrapper,
- .bp3-popover-target{
+ .bp3-popover-target {
display: block;
width: 100%;
}
- .bp3-button{
+ .bp3-button {
width: 100%;
justify-content: start;
}
}
-
-.bp3-datepicker-caption .bp3-html-select::after{
+.bp3-datepicker-caption .bp3-html-select::after {
margin-right: 6px;
}
-.hint{
+.hint {
margin-left: 6px;
position: relative;
top: -1px;
-
- .bp3-icon{
- color: #A1B2C5;
+
+ .bp3-icon {
+ color: #a1b2c5;
}
- .bp3-popover-target:hover .bp3-icon{
+ .bp3-popover-target:hover .bp3-icon {
color: #90a1b5;
}
- .bp3-icon{
+ .bp3-icon {
vertical-align: middle;
}
}
@@ -155,20 +153,19 @@ body.authentication {
}
}
-.bp3-form-group .bp3-label{
-
- .hint{
- .bp3-popover-wrapper{
+.bp3-form-group .bp3-label {
+ .hint {
+ .bp3-popover-wrapper {
display: inline;
}
}
- &:not(.bp3-inline) .hint .bp3-popover-target{
+ &:not(.bp3-inline) .hint .bp3-popover-target {
display: inline;
margin-left: 0;
}
}
-.bp3-popover.bp3-tooltip{
+.bp3-popover.bp3-tooltip {
max-width: 300px;
}
diff --git a/client/src/style/pages/estimate.scss b/client/src/style/pages/estimate.scss
new file mode 100644
index 000000000..fe0f1867b
--- /dev/null
+++ b/client/src/style/pages/estimate.scss
@@ -0,0 +1,379 @@
+.estimate-form {
+ padding-bottom: 30px;
+ display: flex;
+ flex-direction: column;
+ .bp3-form-group {
+ width: 100%;
+ margin: 25px 20px 15px;
+ }
+ .bp3-label {
+ margin: 0 20px 0;
+ font-weight: 500;
+ font-size: 13px;
+ color: #444;
+ width: 130px;
+ }
+ .bp3-form-content {
+ width: 35%;
+ .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) {
+ width: 120%;
+ }
+ }
+
+ &__table {
+ padding: 15px 15px 0;
+
+ .bp3-form-group {
+ margin-bottom: 0;
+ }
+ .table {
+ border: 1px dotted rgb(195, 195, 195);
+ border-bottom: transparent;
+ border-left: transparent;
+
+ .th,
+ .td {
+ border-left: 1px dotted rgb(195, 195, 195);
+
+ &.index {
+ > span,
+ > div {
+ text-align: center;
+ width: 100%;
+ font-weight: 500;
+ }
+ }
+ }
+
+ .thead {
+ .tr .th {
+ padding: 10px 10px;
+ background-color: #f2f5fa;
+ font-size: 14px;
+ font-weight: 500;
+ color: #333;
+ }
+ }
+
+ .tbody {
+ .tr .td {
+ padding: 7px;
+ border-bottom: 1px dotted rgb(195, 195, 195);
+ min-height: 46px;
+
+ &.index {
+ background-color: #f2f5fa;
+ text-align: center;
+
+ > span {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
+ }
+ .tr {
+ .bp3-form-group .bp3-input,
+ .form-group--select-list .bp3-button {
+ border-radius: 3px;
+ padding-left: 8px;
+ padding-right: 8px;
+ }
+
+ .bp3-form-group:not(.bp3-intent-danger) .bp3-input,
+ .form-group--select-list:not(.bp3-intent-danger) .bp3-button {
+ border-color: #e5e5e5;
+ }
+
+ &:last-of-type {
+ .td {
+ border-bottom: transparent;
+
+ .bp3-button,
+ .bp3-input-group {
+ display: none;
+ }
+ }
+ }
+
+ .td.actions {
+ .bp3-button {
+ background-color: transparent;
+ color: #e68f8e;
+
+ &:hover {
+ color: #c23030;
+ }
+ }
+ }
+
+ &.row--total {
+ .td.amount {
+ font-weight: bold;
+ }
+ }
+ }
+ }
+ .th {
+ color: #444;
+ font-weight: 600;
+ border-bottom: 1px dotted #666;
+ }
+
+ .td {
+ border-bottom: 1px dotted #999;
+
+ &.description {
+ .bp3-form-group {
+ width: 100%;
+ }
+ }
+ }
+
+ .actions.td {
+ .bp3-button {
+ background: transparent;
+ margin: 0;
+ }
+ }
+ }
+ }
+
+ &__floating-footer {
+ position: fixed;
+ bottom: 0;
+ width: 100%;
+ background: #fff;
+ padding: 18px 18px;
+ border-top: 1px solid #ececec;
+
+ .has-mini-sidebar & {
+ left: 50px;
+ }
+ }
+ .bp3-button {
+ &.button--clear-lines {
+ background-color: #fcefef;
+ }
+ }
+ .button--clear-lines,
+ .button--new-line {
+ padding-left: 14px;
+ padding-right: 14px;
+ }
+ .dropzone-container {
+ margin-top: 0;
+ align-self: flex-end;
+ }
+ .dropzone {
+ width: 300px;
+ height: 75px;
+ }
+
+ .form-group--description {
+ .bp3-label {
+ font-weight: 500;
+ font-size: 13px;
+ color: #444;
+ }
+ .bp3-form-content {
+ // width: 280px;
+ textarea {
+ width: 450px;
+ min-height: 75px;
+ }
+ }
+ }
+}
+
+
+// .estimate-form {
+// padding-bottom: 30px;
+// display: flex;
+// flex-direction: column;
+
+// .bp3-form-group {
+// margin: 25px 20px 15px;
+// width: 100%;
+// .bp3-label {
+// font-weight: 500;
+// font-size: 13px;
+// color: #444;
+// width: 130px;
+// }
+// .bp3-form-content {
+// // width: 400px;
+// width: 45%;
+// }
+// }
+// // .expense-form-footer {
+// // display: flex;
+// // padding: 30px 25px 0;
+// // justify-content: space-between;
+// // }
+
+// &__primary-section {
+// background: #fbfbfb;
+// }
+// &__table {
+// padding: 15px 15px 0;
+
+// .bp3-form-group {
+// margin-bottom: 0;
+// }
+// .table {
+// border: 1px dotted rgb(195, 195, 195);
+// border-bottom: transparent;
+// border-left: transparent;
+
+// .th,
+// .td {
+// border-left: 1px dotted rgb(195, 195, 195);
+
+// &.index {
+// > span,
+// > div {
+// text-align: center;
+// width: 100%;
+// font-weight: 500;
+// }
+// }
+// }
+
+// .thead {
+// .tr .th {
+// padding: 10px 10px;
+// background-color: #f2f5fa;
+// font-size: 14px;
+// font-weight: 500;
+// color: #333;
+// }
+// }
+
+// .tbody {
+// .tr .td {
+// padding: 7px;
+// border-bottom: 1px dotted rgb(195, 195, 195);
+// min-height: 46px;
+
+// &.index {
+// background-color: #f2f5fa;
+// text-align: center;
+
+// > span {
+// margin-top: auto;
+// margin-bottom: auto;
+// }
+// }
+// }
+// .tr {
+// .bp3-form-group .bp3-input,
+// .form-group--select-list .bp3-button {
+// border-radius: 3px;
+// padding-left: 8px;
+// padding-right: 8px;
+// }
+
+// .bp3-form-group:not(.bp3-intent-danger) .bp3-input,
+// .form-group--select-list:not(.bp3-intent-danger) .bp3-button {
+// border-color: #e5e5e5;
+// }
+
+// &:last-of-type {
+// .td {
+// border-bottom: transparent;
+
+// .bp3-button,
+// .bp3-input-group {
+// display: none;
+// }
+// }
+// }
+
+// .td.actions {
+// .bp3-button {
+// background-color: transparent;
+// color: #e68f8e;
+
+// &:hover {
+// color: #c23030;
+// }
+// }
+// }
+
+// &.row--total {
+// .td.amount {
+// font-weight: bold;
+// }
+// }
+// }
+// }
+// .th {
+// color: #444;
+// font-weight: 600;
+// border-bottom: 1px dotted #666;
+// }
+
+// .td {
+// border-bottom: 1px dotted #999;
+
+// &.description {
+// .bp3-form-group {
+// width: 100%;
+// }
+// }
+// }
+
+// .actions.td {
+// .bp3-button {
+// background: transparent;
+// margin: 0;
+// }
+// }
+// }
+// }
+// &__floating-footer {
+// position: fixed;
+// bottom: 0;
+// width: 100%;
+// background: #fff;
+// padding: 18px 18px;
+// border-top: 1px solid #ececec;
+
+// .has-mini-sidebar & {
+// left: 50px;
+// }
+// }
+// .bp3-button {
+// &.button--clear-lines {
+// background-color: #fcefef;
+// }
+// }
+// .button--clear-lines,
+// .button--new-line {
+// padding-left: 14px;
+// padding-right: 14px;
+// }
+// .dropzone-container {
+// margin-top: 0;
+// align-self: flex-end;
+// }
+// .dropzone {
+// width: 300px;
+// height: 75px;
+// }
+
+// .form-group--description {
+// .bp3-label {
+// font-weight: 500;
+// font-size: 13px;
+// color: #444;
+// }
+// .bp3-form-content {
+// // width: 280px;
+// textarea {
+// width: 450px;
+// min-height: 75px;
+// }
+// }
+// }
+// }