,
+ text: ,
href: '/expenses/new',
},
],
@@ -140,12 +153,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/Sales/Estimate/EntriesItemsTable.js b/client/src/containers/Sales/Estimate/EntriesItemsTable.js
new file mode 100644
index 000000000..eaa0a370e
--- /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, transformUpdatedRows } 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),
+ })),
+ );
+ },
+ [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..dca9ab169
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateActionsBar.js
@@ -0,0 +1,140 @@
+import React, { useMemo, useCallback } 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 } from 'react-intl';
+
+import { If } 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 onClickNewEstimate = useCallback(() => {
+ // history.push('/estimates/new');
+ }, [history]);
+
+ const filterDropdown = FilterDropdown({
+ initialCondition: {
+ fieldKey: '',
+ compatator: '',
+ value: '',
+ },
+ fields: resourceFields,
+ onFilterChange: (filterConditions) => {
+ addEstimatesTableQueries({
+ filter_roles: filterConditions || '',
+ });
+ onFilterChanged && onFilterChange(filterConditions);
+ },
+ });
+
+ const hasSelectedRows = useMemo(() => selectedRows.length > 0, [
+ selectedRows,
+ ]);
+
+ return (
+
+
+
+ }
+ text={}
+ onClick={onClickNewEstimate}
+ />
+
+ }
+ />
+
+
+ }
+ text={}
+ intent={Intent.DANGER}
+ // onClick={handleBulkDelete}
+ />
+
+ }
+ text={}
+ />
+
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+
+
+ );
+}
+
+const mapStateToProps = (state, props) => ({
+ resourceName: 'estimates',
+});
+
+const withEstimateActionsBar = connect(mapStateToProps);
+
+export default compose(
+ withEstimateActionsBar,
+ withDialogActions,
+ withResourceDetail(({ resourceFields }) => ({
+ resourceFields,
+ })),
+ // withEstimate(({ estimateViews }) => ({
+ // estimateViews,
+ // })),
+ 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..aceb930f0
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateForm.js
@@ -0,0 +1,334 @@
+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, omitBy, omit } from 'lodash';
+
+import EstimateFormHeader from './EstimateFormHeader';
+import EstimatesItemsTable from './EntriesItemsTable';
+import EstimateFormFooter from './EstimateFormFooter';
+
+import withEstimateActions from './withEstimateActions';
+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, setPaload] = 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()
+ .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 saveInvokeSubmit = useCallback(
+ (payload) => {
+ onFormSubmit && onFormSubmit(payload);
+ },
+ [onFormSubmit],
+ );
+
+ const defaultEstimate = useMemo(
+ () => ({
+ index: 0,
+ item_id: null,
+ rate: null,
+ discount: null,
+ quantity: null,
+ description: '',
+ }),
+ [],
+ );
+
+ const defaultInitialValues = useMemo(
+ () => ({
+ customer_id: null,
+ estimate_date: moment(new Date()).format('YYYY-MM-DD'),
+ expiration_date: moment(new Date()).format('YYYY-MM-DD'),
+ estimate_number: null,
+ reference: '',
+ note: '',
+ terms_conditions: '',
+ entries: [...repeatValue(defaultEstimate, MIN_LINES_NUMBER)],
+ }),
+ [defaultEstimate],
+ );
+
+ const orderingProductsIndex = (_entries) => {
+ return _entries.map((item, index) => ({
+ ...item,
+ index: index + 1,
+ }));
+ };
+
+ 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.map((item) => omit(item, ['total']));
+
+ const form = {
+ ...values,
+ entries,
+ };
+ const saveEstimate = (mediaIds) =>
+ new Promise((resolve, reject) => {
+ const requestForm = { ...form, media_ids: mediaIds };
+
+ 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();
+ saveInvokeSubmit({ 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 saveEstimate(saveEstimate.current);
+ });
+ },
+ });
+
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPaload(payload);
+ formik.submitForm();
+ },
+ [setPaload, 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,
+)(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..d1881d750
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateFormFooter.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 EstimateFormFooter({
+ formik: { isSubmitting },
+ onSubmitClick,
+ onCancelClick,
+}) {
+ 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..cc91019d9
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimateList.js
@@ -0,0 +1,120 @@
+import React, { useEffect, useCallback, useMemo } 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 withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withEstimateActions from './withEstimateActions';
+
+import EstimateActionsBar from './EstimateActionsBar';
+
+import { compose } from 'utils';
+
+function EstimateList({
+ // #withDashboardActions
+ changePageTitle,
+
+ // #withViewsActions
+
+ // #withEstimate
+
+ //#withEistimateActions
+ requestFetchEstimatesTable,
+ requestDeleteEstimate,
+ addEstimatesTableQueries,
+}) {
+ const history = useHistory();
+ const { formatMessage } = useIntl();
+ const [deleteEstimate, setDeleteEstimate] = useState(false);
+ const [selectedRows, setSelectedRows] = useState([]);
+
+ const fetchEstimate = useQuery(['estimate-table'], () =>
+ 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((response) => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_estimate_has_been_successfully_deleted',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setDeleteEstimate(false);
+ });
+ }, [deleteEstimate, requestDeleteEstimate, formatMessage]);
+
+ // Calculates the selected rows
+ const selectedRowsCount = useMemo(() => Object.values(selectedRows).length, [
+ selectedRows,
+ ]);
+
+ const handleEidtEstimate = 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) => {
+ selectedRows(estimate);
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export default EstimateList;
diff --git a/client/src/containers/Sales/Estimate/Estimates.js b/client/src/containers/Sales/Estimate/Estimates.js
new file mode 100644
index 000000000..545527388
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/Estimates.js
@@ -0,0 +1,49 @@
+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 }) {
+ const history = useHistory();
+ const { id } = useParams();
+
+ // Handle fetch customers data table or list
+ 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(
+ 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..d4445fdae
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/EstimatesDataTable.js
@@ -0,0 +1,196 @@
+import React, { useEffect, useCallback, useState, useMemo } from 'react';
+import {
+ Intent,
+ Button,
+ Classes,
+ Popover,
+ Tooltip,
+ Menu,
+ MenuItem,
+ MenuDivider,
+ Position,
+ Tag,
+} 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 { If } from 'components';
+import DataTable from 'components/DataTable';
+
+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';
+
+function EstimatesDataTable({
+ //#withEitimates
+
+ // #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 (!estimateLoading) {
+ // setInitialMount(true);
+ // }
+ // }, []);
+
+ 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(() => {
+ onDeleteEstimate && onDeleteEstimate();
+ }, [onDeleteEstimate]);
+
+ const actionMenuList = useCallback(
+ () => (
+
+ ),
+ [handleDeleteEstimate, handleEditEstimate, formatMessage],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: 'actions',
+ Header: '',
+ Cell: ({ cell }) => (
+
+ } />
+
+ ),
+ className: 'actions',
+ width: 50,
+ disableResizing: true,
+ },
+ ],
+ [actionMenuList, formatMessage],
+ );
+
+ const handleDataTableFetchData = useCallback(
+ (...arguments) => {
+ onFetchData && onFetchData(...arguments);
+ },
+ [onFetchData],
+ );
+
+ const handleSelectedRowsChange = useCallback(
+ (selectedRows) => {
+ onSelectedRowsChange &&
+ onSelectedRowsChange(selectedRows.map((s) => s.original));
+ },
+ [onSelectedRowsChange],
+ );
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withRouter,
+ withDialogActions,
+ withDashboardActions,
+ withEstimateActions,
+ // withEstimates(({}) => ({})),
+ 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..574bc401d
--- /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..b64faa14e
--- /dev/null
+++ b/client/src/containers/Sales/Estimate/withEstimates.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { getResourceViews } from 'store/customViews/customViews.selectors';
+import {
+ getEstimateCurrentPage,
+ getEstimatesTableQuery,
+ getEstimatesPaginationMetaFactory,
+} from 'store/Estimate/estimates.selectors';
+
+function withEstimates(mapSate) {
+ const mapStateToProps = (state, props) => {
+ const query = getEstimatesTableQuery(state, props);
+ const mapped = {
+ estimateViews: getResourceViews(state, props, 'estimates'),
+ estimateItems: state.estiamte.items,
+ estimateTableQuery: query,
+ estimatesLoading: state.estiamte.loading,
+ };
+ return mapSate ? mapSate(mapped, state, props) : mapped;
+ };
+
+ return connect(mapStateToProps);
+}
+
+export default withEstimates;
diff --git a/client/src/containers/Sales/Invoice/InvoiceActionsBar.js b/client/src/containers/Sales/Invoice/InvoiceActionsBar.js
new file mode 100644
index 000000000..b8d1e2ed9
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceActionsBar.js
@@ -0,0 +1,133 @@
+import React, { useMemo, useCallback } 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 } from 'react-intl';
+
+import { If } 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 withInvoiceActions from './withInvoices';
+
+import { compose } from 'utils';
+import { connect } from 'react-redux';
+
+function InvoiceActionsBar({
+ // #withResourceDetail
+ resourceFields,
+
+ //#withInvoice
+ InvoiceViews,
+
+ // #withInvoiceActions
+ addInvoiceTableQueries,
+
+ // #own Porps
+ onFilterChanged,
+ selectedRows,
+}) {
+ const history = useHistory();
+
+ const FilterDropdown = FilterDropdown({
+ initialCondition: {
+ fieldKey: '',
+ compatator: '',
+ value: '',
+ },
+ fields: resourceFields,
+ onFilterChange: (filterConditions) => {
+ addInvoiceTableQueries({
+ filter_roles: filterConditions || '',
+ });
+ onFilterChanged && onFilterChanged(filterConditions);
+ },
+ });
+
+ const hasSelectedRows = useMemo(() => selectedRows.length > 0, [
+ selectedRows,
+ ]);
+
+ return (
+
+
+
+ }
+ text={}
+ onClick={onClickNewInvoice}
+ />
+
+ }
+ />
+
+
+ }
+ text={}
+ intent={Intent.DANGER}
+ // onClick={handleBulkDelete}
+ />
+
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+
+
+ );
+}
+
+const mapStateToProps = (state, props) => ({
+ resourceName: 'invoice',
+});
+
+const withInvoiceActionsBar = connect(mapStateToProps);
+
+export default compose(
+ withInvoiceActionsBar,
+ withDialogActions,
+ withResourceDetail(({ resourceFields }) => ({
+ resourceFields,
+ })),
+ // withInvoices(({ invoiceViews }) => ({
+ // invoiceViews,
+ // })),
+ 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..64768d7c5
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceForm.js
@@ -0,0 +1,321 @@
+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 InvoiceFormHeader from './InvoiceFormHeader';
+import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable';
+import InvoiceFormFooter from './InvoiceFormFooter';
+
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withMediaActions from 'containers/Media/withMediaActions';
+import withInvoiceActions from './withInvoiceActions';
+
+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,
+
+ //#withDashboard
+ changePageTitle,
+ changePageSubtitle,
+
+ //#withInvoiceDetail
+ invoice,
+
+ //#own Props
+ InvoiceId,
+ onFormSubmit,
+ onCancelForm,
+}) {
+ const { formatMessage } = useIntl();
+ const [payload, setPaload] = 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(),
+ }),
+ total: Yup.number().nullable(),
+ 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: null,
+ 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: 'status',
+ 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,
+ }));
+ };
+
+ 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.map((item) => omit(item, ['total']));
+
+ const form = {
+ ...values,
+ entries,
+ };
+ const saveInvoice = (mediaIds) =>
+ new Promise((resolve, reject) => {
+ const requestForm = { ...form, media_ids: mediaIds };
+
+ requestSubmitInvoice(requestForm)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage(
+ { id: 'the_invocie_has_been_successfully_created' },
+ { number: values.invoice_no },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ resetForm();
+ saveInvokeSubmit({ 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 saveInvoice(savedMediaIds.current);
+ });
+ },
+ });
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPaload(payload);
+ formik.submitForm();
+ },
+ [setPaload, formik],
+ );
+
+ const handleCancelClick = useCallback(
+ (payload) => {
+ onCancelForm && onCancelForm(payload);
+ },
+ [onCancelForm],
+ );
+
+ console.log(formik.errors, 'Errors');
+ 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,
+)(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..d1881d750
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceFormFooter.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 EstimateFormFooter({
+ formik: { isSubmitting },
+ onSubmitClick,
+ onCancelClick,
+}) {
+ 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..f80af54b2
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoiceList.js
@@ -0,0 +1,109 @@
+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 withDashboardActions from 'containers/Dashboard/withDashboardActions';
+// import withInvoiceActions from './withInvoiceActions'
+
+// import InvoiceActionsBar from './InvoiceActionsBar';
+
+import { compose } from 'utils';
+import InvoiceActionsBar from './InvoiceActionsBar';
+
+function InvoiceList({
+ // #withDashboardActions
+ changePageTitle,
+
+ // #withViewsActions
+
+ //#withInvoice
+
+ //#withInvoiceActions
+}) {
+ const history = useHistory();
+ const { formatMessage } = useIntl();
+ const [deleteInvoice, setDeleteInvoice] = useState(false);
+ const [selectedRows, setSelectedRows] = useState([]);
+
+ useEffect(() => {
+ changePageTitle(formatMessage({ id: 'invoice_list' }));
+ }, [changePageTitle, formatMessage]);
+
+ //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);
+ });
+ }, [setDeleteInvoice, requestDeleteInvoice]);
+
+ const handleEditInvoice = useCallback((invoice) => {
+ history.push(`/invoices/${invoice.id}/edit`);
+ });
+
+ const fetchInvoice = useQuery(['invoice-table'], () =>
+ requsetFetchInvoiceTable(),
+ );
+
+ 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],
+ );
+ const handleSelectedRowsChange = useCallback((_invoice) => {
+ selectedRows(_invoice);
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export default InvoiceList;
diff --git a/client/src/containers/Sales/Invoice/Invoices.js b/client/src/containers/Sales/Invoice/Invoices.js
new file mode 100644
index 000000000..e5804c8f4
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/Invoices.js
@@ -0,0 +1,49 @@
+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 }) {
+ const history = useHistory();
+ const { id } = useParams();
+
+ // Handle fetch Items data table or list
+ const fetchItems = useQuery('items-table', () => requestFetchItems({}));
+
+ const handleFormSubmit = useCallback((payload) => {}, [history]);
+
+ // Handle fetch customers data table or list
+ const fetchCustomers = useQuery('customers-table', () =>
+ requestFetchCustomers({}),
+ );
+
+ 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..6a937410d
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/InvoicesDataTable.js
@@ -0,0 +1,189 @@
+import React, { useEffect, useCallback, useState, useMemo } from 'react';
+import {
+ Intent,
+ Button,
+ Classes,
+ Popover,
+ Tooltip,
+ Menu,
+ MenuItem,
+ MenuDivider,
+ Position,
+ Tag,
+} 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 { If } from 'components';
+import DataTable from 'components/DataTable';
+
+import withDialogActions from 'containers/Dialog/withDialogActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+
+import witInvoice from './withInvoice';
+import withInvoiceActions from './withInvoiceActions';
+
+function InvoicesDataTable({
+ //#withInvoices
+
+ // #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);
+ }, []);
+
+ 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(() => {
+ onDeleteInvoice && onDeleteInvoice();
+ }, [onDeleteInvoice]);
+
+ const actionsMenuList = useCallback(
+ (invoice) => {
+ ;
+ },
+ [handleDeleteInvoice, handleEditInvoice, formatMessage],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: '',
+ Header: formatMessage({ id: '' }),
+ accessor: '',
+ className: '',
+ },
+ {
+ id: 'actions',
+ Header: '',
+ Cell: ({ cell }) => (
+
+ } />
+
+ ),
+ className: 'actions',
+ width: 50,
+ disableResizing: true,
+ },
+ ],
+ [actionMenuList, formatMessage],
+ );
+
+ const handleDataTableFetchData = useCallback(
+ (...arguments) => {
+ onFetchData && onFetchData(...arguments);
+ },
+ [onFetchData],
+ );
+
+ const handleSelectedRowsChange = useCallback(
+ (selectedRows) => {
+ onSelectedRowsChange &&
+ onSelectedRowsChange(selectedRows.map((s) => s.original));
+ },
+ [onSelectedRowsChange],
+ );
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withRouter,
+ withDialogActions,
+ withDashboardActions,
+ withInvoiceActions,
+ // withInvoices(({})=>({
+
+ // }))
+ 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..f00d5cf8f
--- /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_CURREMT_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..c7f8623e9
--- /dev/null
+++ b/client/src/containers/Sales/Invoice/withInvoiceDetail.js
@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import { getInvoiceById } from 'store/Invoice/invoices.selector';
+
+export default () => {
+ const getInvoiceById = getInvoiceById();
+
+ const mapStateToProps = (state, props) => ({
+ invoice: getInvoiceById(state, props),
+ });
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Sales/Receipt/ReceiptActionsBar.js b/client/src/containers/Sales/Receipt/ReceiptActionsBar.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/containers/Sales/Receipt/ReceiptForm.js b/client/src/containers/Sales/Receipt/ReceiptForm.js
new file mode 100644
index 000000000..57c3ee7ac
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/ReceiptForm.js
@@ -0,0 +1,325 @@
+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 withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withMediaActions from 'containers/Media/withMediaActions';
+import withReceipActions from './withReceipActions';
+
+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,
+
+ //#withDashboard
+ changePageTitle,
+ changePageSubtitle,
+
+ //#withReceiptDetail
+ receipt,
+
+ //#own Props
+ receiptId,
+ onFormSubmit,
+ onCancelForm,
+}) {
+ const { formatMessage } = useIntl();
+ const [payload, setPaload] = 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_' })),
+ send_to_email: Yup.string().email(),
+ 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 saveReceiptSubmit = useCallback(
+ (payload) => {
+ onFormSubmit && onFormSubmit(payload);
+ },
+ [onFormSubmit],
+ );
+
+ const defaultReceipt = useMemo(
+ () => ({
+ index: 0,
+ item_id: null,
+ rate: null,
+ discount: null,
+ quantity: null,
+ description: '',
+ }),
+ [],
+ );
+
+ const defaultInitialValues = useMemo(
+ () => ({
+ customer_id: '',
+ deposit_account_id: '',
+ receipt_date: moment(new Date()).format('YYYY-MM-DD'),
+ send_to_email: '',
+ 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(
+ () => ({
+ ...defaultInitialValues,
+ entries: orderingIndex(defaultInitialValues.entries),
+ }),
+ [defaultReceipt, defaultInitialValues, receipt],
+ );
+
+ 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.map(
+ ({ item_id, quantity, rate, description }) => ({
+ item_id,
+ quantity,
+ rate,
+ description,
+ }),
+ );
+ const form = {
+ ...values,
+ entries,
+ };
+
+ const saveReceipt = (mediaIds) =>
+ new Promise((resolve, reject) => {
+ const requestForm = { ...form, media_ids: mediaIds };
+
+ requestSubmitReceipt(requestForm)
+ .then((resposne) => {
+ AppToaster.show({
+ message: formatMessage({
+ id: 'the_receipt_has_been_successfully_created',
+ }),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ resetForm();
+ saveReceiptSubmit({ 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 saveReceipt(saveReceipt.current);
+ });
+ },
+ });
+
+ const handleDeleteFile = useCallback(
+ (_deletedFiles) => {
+ _deletedFiles.forEach((deletedFile) => {
+ if (deletedFile.uploaded && deletedFile.metadata.id) {
+ setDeletedFiles([...deletedFiles, deletedFile.metadata.id]);
+ }
+ });
+ },
+ [setDeletedFiles, deletedFiles],
+ );
+
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPaload(payload);
+ formik.submitForm();
+ },
+ [setPaload, 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,
+)(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..e25ec3228
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/ReceiptFormFooter.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 ReceiptFormFooter({
+ formik: { isSubmitting },
+ onSubmitClick,
+ onCancelClick,
+}) {
+ 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..443d6f8f5
--- /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.send_to_email && touched.send_to_email && Intent.DANGER}
+ helperText={
}
+ >
+
+
+
+ );
+}
+
+export default compose(
+ withCustomers(({ customers }) => ({
+ customers,
+ })),
+ withAccounts(({ accountsList }) => ({
+ accountsList,
+ })),
+)(ReceiptFormHeader);
diff --git a/client/src/containers/Sales/Receipt/Receipts.js b/client/src/containers/Sales/Receipt/Receipts.js
new file mode 100644
index 000000000..473d3cb8a
--- /dev/null
+++ b/client/src/containers/Sales/Receipt/Receipts.js
@@ -0,0 +1,65 @@
+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 { compose } from 'utils';
+
+function Receipts({
+ //#withwithAccountsActions
+ requestFetchAccounts,
+
+ //#withCustomersActions
+ requestFetchCustomers,
+
+ //#withItemsActions
+ requestFetchItems,
+}) {
+ const history = useHistory();
+ const { id } = useParams();
+
+ 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) => {}, [history]);
+
+ const handleCancel = useCallback(() => {
+ history.goBack();
+ }, [history]);
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withCustomersActions,
+ withItemsActions,
+ withAccountsActions,
+)(Receipts);
diff --git a/client/src/containers/Sales/Receipt/withReceipActions.js b/client/src/containers/Sales/Receipt/withReceipActions.js
new file mode 100644
index 000000000..1aebb3884
--- /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 })),
+ requestEditTeceipt: (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.RECEIPT_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/lang/en/index.js b/client/src/lang/en/index.js
index b501e64b7..57590e1ee 100644
--- a/client/src/lang/en/index.js
+++ b/client/src/lang/en/index.js
@@ -528,14 +528,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 +552,85 @@ 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',
+
+ 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.',
+ cannot_be_zero_or_empty: 'cannot be zero or empty.',
+ invocies: 'Invoices',
+ invoice_date: 'Invoice Date',
+ due_date: 'Due Date',
+ invoice_date_: 'Invoice date',
+ invoice_no: 'Invoice #',
+ invoice_no_: 'Invoice number',
+ due_date_: 'Due date',
+ invoice_message: 'Invoice Message',
+
+ 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.',
+ 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',
+ 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',
+ 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.',
};
diff --git a/client/src/routes/dashboard.js b/client/src/routes/dashboard.js
index dc4ec2e2b..d5b73c84a 100644
--- a/client/src/routes/dashboard.js
+++ b/client/src/routes/dashboard.js
@@ -204,4 +204,75 @@ 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/EstimatesList'),
+ // }),
+ // breadcrumb: 'Estimates',
+ // },
+
+ //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/Invoices')
+ // }),
+ // breadcrumb: 'New Invoice',
+ // },
+
+ //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/Receipts'),
+ // }),
+ // breadcrumb: 'New Receipt',
+ // }
];
diff --git a/client/src/store/Estimate/estimates.actions.js b/client/src/store/Estimate/estimates.actions.js
new file mode 100644
index 000000000..c67d8ab80
--- /dev/null
+++ b/client/src/store/Estimate/estimates.actions.js
@@ -0,0 +1,138 @@
+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(`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(`estimates/${id}`)
+ .then((response) => {
+ dispatch({ type: t.ESTIMATE_DELETE });
+ resovle(response);
+ })
+ .catch((error) => {
+ reject(error.response.data.errors || []);
+ });
+ });
+};
+
+export const fetchEstimate = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.get(`estimate/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.ESTIMATE_SET,
+ payload: {
+ id,
+ estimate: response.data.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().estimates.tableQuery;
+
+ dispatch({
+ type: t.ESTIMATES_TABLE_LOADING,
+ payload: {
+ loading: true,
+ },
+ });
+ ApiService.get('estimates', {
+ params: { ...pageQuery, ...query },
+ })
+ .then((response) => {
+ dispatch({
+ type: t.ESTIMATES_PAGE_SET,
+ payload: {
+ estimates: response.data.estimates.results,
+ pagination: response.data.estimates.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.ESTIMATES_ITEMS_SET,
+ payload: {
+ estimates: response.data.estimates.results,
+ },
+ });
+ dispatch({
+ type: t.ESTIMATES_PAGINATION_SET,
+ payload: {
+ pagination: response.data.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..e52a9ce3b
--- /dev/null
+++ b/client/src/store/Estimate/estimates.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,
+ tableQuery: {
+ page_size: 12,
+ page: 1,
+ },
+ currentViewId: -1,
+};
+
+const defaultEstimate = {
+ products: [],
+};
+
+const reducer = createReducer(initialState, {
+ [t.ESTIMATE_SET]: (state, action) => {
+ const { id, estiamate } = action.payload;
+ const _estimate = state.items[id] || {};
+
+ state.items[id] = { ...defaultEstimate, ..._estimate, ...estiamate };
+ },
+ [t.ESTIMATES_ITEMS_SET]: (state, action) => {
+ const { estiamates } = action.payload;
+ const _estimates = {};
+ estiamates.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) => {
+ const { customViewId, 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: 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('estimates', reducer);
+
+export const getEstimateById = (state, id) => {
+ return state.estiamates.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..d75d0e0fb
--- /dev/null
+++ b/client/src/store/Estimate/estimates.selectors.js
@@ -0,0 +1,44 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { pickItemsFromIds, paginationLocationQuery } from 'store/selectors';
+
+const estimateTableQuery = (state) => state.estimates.tableQuery;
+
+export const getEstimatesTableQuery = createSelector(
+ paginationLocationQuery,
+ estimateTableQuery,
+ (location, query) => ({
+ ...location,
+ ...query,
+ }),
+);
+
+const estimatesSelector = (state, props, query) => {
+ const viewId = state.estimates.currentViewId;
+ return state.estimates.views?.[viewId]?.pages?.[query.page];
+};
+
+const EstimateItemsSelector = (state) => state.estimates.items;
+
+export const getEstimateCurrentPage = () =>
+ createSelector(estimatesSelector, EstimateItemsSelector, (page, items) => {
+ return typeof page === 'object'
+ ? pickItemsFromIds(items, page.ids) || []
+ : [];
+ });
+
+const estimateByIdSelector = (state, props) => state.estimates.items;
+
+export const getEstimateByIdFactory = () =>
+ createSelector(estimateByIdSelector, (estimate) => {
+ return estimate;
+ });
+
+const paginationSelector = (state, props) => {
+ const viewId = state.estimates.currentViewId;
+ return state.estimates.views?.[viewId];
+};
+
+export const getEstimatesPaginationMetaFactory = () =>
+ createSelector(paginationSelector, (estimatePage) => {
+ return estimatePage?.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..d6673925d
--- /dev/null
+++ b/client/src/store/Invoice/invoices.actions.js
@@ -0,0 +1,137 @@
+import ApiService from 'services/ApiService';
+import t from 'store/types';
+
+export const submitInvoice = ({ form }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
+ });
+ ApiService.post('sales/invoices', 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 deleteInvoice = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.delete(`invoice/${id}`)
+ .then((response) => {
+ dispatch({ type: t.INVOICE_DELETE });
+ 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(`invoice/${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 fetchInvoicesTable = ({ query = {} }) => {
+ return (dispatch, getState) =>
+ new Promise((resolve, rejcet) => {
+ const pageQuery = getState().invoices.tableQuery;
+
+ dispatch({
+ type: t.INVOICES_TABLE_LOADING,
+ payload: {
+ loading: true,
+ },
+ });
+ ApiService.get('invoices', {
+ params: { ...pageQuery, ...query },
+ })
+ .then((response) => {
+ dispatch({
+ type: t.INVOICES_PAGE_SET,
+ payload: {
+ invoices: response.data.invoices.results,
+ pagination: response.data.invoices.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.INVOICES_ITEMS_SET,
+ payload: {
+ invoices: response.data.invoices.results,
+ },
+ });
+ dispatch({
+ type: t.INVOICES_PAGINATION_SET,
+ payload: {
+ pagination: response.data.invoices.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.INVOICES_TABLE_LOADING,
+ payload: {
+ loading: false,
+ },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ rejcet(error);
+ });
+ });
+};
+
+export const fetchInvoice = ({ id }) => {
+ return (dispatch) =>
+ new Promise((resovle, reject) => {
+ ApiService.get(`invoices/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.INVOICE_SET,
+ payload: {
+ id,
+ invoice: response.data.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..d73488705
--- /dev/null
+++ b/client/src/store/Invoice/invoices.reducer.js
@@ -0,0 +1,32 @@
+import { createReducer } from '@reduxjs/toolkit';
+import { createTableQueryReducers } from 'store/queryReducers';
+
+import t from 'store/types';
+
+const initialState = {
+ items: {},
+ views: {},
+ loading: false,
+ tableQuery: {
+ page_size: 12,
+ page: 1,
+ },
+ currentViewId: -1,
+};
+
+const defaultInvoice = {
+ entires: [],
+};
+
+
+
+
+const reducer = createReducer(initialState, {
+[t.INVOICE_SET]:(state,actio)=>{
+
+ const {id,INVOICE_SET} = action.payload;
+
+}
+
+
+});
diff --git a/client/src/store/Invoice/invoices.selector.js b/client/src/store/Invoice/invoices.selector.js
new file mode 100644
index 000000000..9422f83b5
--- /dev/null
+++ b/client/src/store/Invoice/invoices.selector.js
@@ -0,0 +1,9 @@
+import { createSelector } from '@reduxjs/toolkit';
+
+const invoiceByIdSelector = (state, props) =>
+ state.invoices.items[props.invoiceId];
+
+export const getInvoiceById = () =>
+ createSelector(invoiceByIdSelector, (_invoice) => {
+ return _invoice;
+ });
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/receipt/receipt.actions.js b/client/src/store/receipt/receipt.actions.js
new file mode 100644
index 000000000..e6425c442
--- /dev/null
+++ b/client/src/store/receipt/receipt.actions.js
@@ -0,0 +1,136 @@
+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(`receipts/${id}`)
+ .then((response) => {
+ dispatch({ type: t.RECEIPT_DELETE });
+ 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.delete(`receipt/${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(`receipt/${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().receipt.tableQuery;
+
+ dispatch({
+ type: t.RECEIPTS_TABLE_LOADING,
+ payload: {
+ loading: true,
+ },
+ });
+ ApiService.get('receipts', {
+ params: { ...pageQuery, ...query },
+ })
+ .then((response) => {
+ dispatch({
+ type: t.RECEIPTS_PAGE_SET,
+ payload: {
+ receipts: response.data.receipts.results,
+ pagination: response.data.receipts.pagination,
+ customViewId: response.data.customViewId || -1,
+ },
+ });
+ dispatch({
+ type: t.RECEIPTS_ITEMS_SET,
+ payload: {
+ receipts: response.data.receipts.results,
+ },
+ });
+ dispatch({
+ type: t.RECEIPTS_PAGINATION_SET,
+ payload: {
+ pagination: response.data.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..e69de29bb
diff --git a/client/src/store/receipt/receipt.selector.js b/client/src/store/receipt/receipt.selector.js
new file mode 100644
index 000000000..e69de29bb
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..6a2fde88c 100644
--- a/client/src/store/reducers.js
+++ b/client/src/store/reducers.js
@@ -18,6 +18,7 @@ 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 estimates from './Estimate/estimates.reducer';
export default combineReducers({
authentication,
@@ -38,4 +39,5 @@ export default combineReducers({
exchangeRates,
globalErrors,
customers,
+ estimates
});
diff --git a/client/src/store/types.js b/client/src/store/types.js
index 25af78fbb..6a089d1d0 100644
--- a/client/src/store/types.js
+++ b/client/src/store/types.js
@@ -17,6 +17,9 @@ 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';
export default {
...authentication,
@@ -38,4 +41,7 @@ export default {
...register,
...exchangeRate,
...customer,
+ ...estimates,
+ ...invoices,
+ ...receipts
};
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;
+// }
+// }
+// }
+// }