This commit is contained in:
elforjani3
2020-11-02 10:23:19 +02:00
29 changed files with 974 additions and 573 deletions

View File

@@ -9,12 +9,16 @@ const CLASSES = {
PAGE_FORM_HEADER: 'page-form__header', PAGE_FORM_HEADER: 'page-form__header',
PAGE_FORM_HEADER_PRIMARY: 'page-form__primary-section', PAGE_FORM_HEADER_PRIMARY: 'page-form__primary-section',
PAGE_FORM_FOOTER: 'page-form__footer', PAGE_FORM_FOOTER: 'page-form__footer',
PAGE_FORM_FLOATING_ACTIONS: 'page-form__floating-action', PAGE_FORM_FLOATING_ACTIONS: 'page-form__floating-actions',
PAGE_FORM_BILL: 'page-form--bill', PAGE_FORM_BILL: 'page-form--bill',
PAGE_FORM_ESTIMATE: 'page-form--estimate', PAGE_FORM_ESTIMATE: 'page-form--estimate',
PAGE_FORM_INVOICE: 'page-form--invoice', PAGE_FORM_INVOICE: 'page-form--invoice',
PAGE_FORM_RECEIPT: 'page-form--receipt', PAGE_FORM_RECEIPT: 'page-form--receipt',
PAGE_FORM_PAYMENT_MADE: 'page-form--payment-made',
CLOUD_SPINNER: 'cloud-spinner',
IS_LOADING: 'is-loading',
...Classes, ...Classes,
}; };

View File

@@ -0,0 +1,23 @@
import React from 'react';
import classNames from 'classnames';
import { Spinner } from '@blueprintjs/core';
import { CLASSES } from 'common/classes';
import If from './Utils/If';
export default function CloudLoadingIndicator({
isLoading,
children,
}) {
return (
<div className={classNames(
CLASSES.CLOUD_SPINNER,
{ [CLASSES.IS_LOADING]: isLoading },
)}>
<If condition={isLoading}>
<Spinner size={30} value={null} />
</If>
{ children }
</div>
);
}

View File

@@ -28,6 +28,7 @@ import InputPrependButton from './Forms/InputPrependButton';
import CategoriesSelectList from './CategoriesSelectList'; import CategoriesSelectList from './CategoriesSelectList';
import Row from './Grid/Row'; import Row from './Grid/Row';
import Col from './Grid/Col'; import Col from './Grid/Col';
import CloudLoadingIndicator from './CloudLoadingIndicator';
const Hint = FieldHint; const Hint = FieldHint;
@@ -63,4 +64,5 @@ export {
CategoriesSelectList, CategoriesSelectList,
Col, Col,
Row, Row,
CloudLoadingIndicator,
}; };

View File

@@ -5,6 +5,7 @@ import {
editBill, editBill,
fetchBillsTable, fetchBillsTable,
fetchBill, fetchBill,
fetchDueBills,
} from 'store/Bills/bills.actions'; } from 'store/Bills/bills.actions';
import t from 'store/types'; import t from 'store/types';
@@ -15,6 +16,7 @@ const mapDispatchToProps = (dispatch) => ({
requestDeleteBill: (id) => dispatch(deleteBill({ id })), requestDeleteBill: (id) => dispatch(deleteBill({ id })),
requestFetchBillsTable: (query = {}) => requestFetchBillsTable: (query = {}) =>
dispatch(fetchBillsTable({ query: { ...query } })), dispatch(fetchBillsTable({ query: { ...query } })),
requestFetchDueBills: (vendorId) => dispatch(fetchDueBills({ vendorId })),
changeBillView: (id) => changeBillView: (id) =>
dispatch({ dispatch({

View File

@@ -4,14 +4,16 @@ import {
getBillCurrentPageFactory, getBillCurrentPageFactory,
getBillPaginationMetaFactory, getBillPaginationMetaFactory,
getBillTableQueryFactory, getBillTableQueryFactory,
getVendorDueBillsFactory getVendorPayableBillsFactory,
getPayableBillsByPaymentMadeFactory
} from 'store/Bills/bills.selectors'; } from 'store/Bills/bills.selectors';
export default (mapState) => { export default (mapState) => {
const getBillsItems = getBillCurrentPageFactory(); const getBillsItems = getBillCurrentPageFactory();
const getBillsPaginationMeta = getBillPaginationMetaFactory(); const getBillsPaginationMeta = getBillPaginationMetaFactory();
const getBillTableQuery = getBillTableQueryFactory(); const getBillTableQuery = getBillTableQueryFactory();
const getVendorDueBills = getVendorDueBillsFactory(); const getVendorPayableBills = getVendorPayableBillsFactory();
const getPayableBillsByPaymentMade = getPayableBillsByPaymentMadeFactory();
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {
const tableQuery = getBillTableQuery(state, props); const tableQuery = getBillTableQuery(state, props);
@@ -26,7 +28,8 @@ export default (mapState) => {
billsLoading: state.bills.loading, billsLoading: state.bills.loading,
nextBillNumberChanged: state.bills.nextBillNumberChanged, nextBillNumberChanged: state.bills.nextBillNumberChanged,
vendorDueBills: getVendorDueBills(state, props), vendorPayableBills: getVendorPayableBills(state, props),
paymentMadePayableBills: getPayableBillsByPaymentMade(state, props),
}; };
return mapState ? mapState(mapped, state, props) : mapped; return mapState ? mapState(mapped, state, props) : mapped;
}; };

View File

@@ -1,10 +1,12 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react'; import React, { useEffect } from 'react';
import { useParams, useHistory } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { useIntl } from 'react-intl';
import PaymentMadeForm from './PaymentMadeForm'; import PaymentMadeForm from './PaymentMadeForm';
import DashboardInsider from 'components/Dashboard/DashboardInsider'; import DashboardInsider from 'components/Dashboard/DashboardInsider';
import withDashboardActions from 'containers/Dashboard/withDashboardActions';
import withVenderActions from 'containers/Vendors/withVendorActions'; import withVenderActions from 'containers/Vendors/withVendorActions';
import withAccountsActions from 'containers/Accounts/withAccountsActions'; import withAccountsActions from 'containers/Accounts/withAccountsActions';
import withItemsActions from 'containers/Items/withItemsActions'; import withItemsActions from 'containers/Items/withItemsActions';
@@ -15,7 +17,7 @@ import withSettingsActions from 'containers/Settings/withSettingsActions';
import { compose } from 'utils'; import { compose } from 'utils';
function PaymentMade({ function PaymentMade({
//#withwithAccountsActions //#withAccountsActions
requestFetchAccounts, requestFetchAccounts,
//#withVenderActions //#withVenderActions
@@ -27,65 +29,61 @@ function PaymentMade({
//#withPaymentMadesActions //#withPaymentMadesActions
requestFetchPaymentMade, requestFetchPaymentMade,
//#withBillActions
// #withSettingsActions // #withSettingsActions
requestFetchOptions, requestFetchOptions,
}) {
const history = useHistory();
const { id } = useParams();
const [venderId, setVenderId] = useState(null);
// Handle fetch accounts data // #withDashboardActions
changePageTitle,
}) {
const { id: paymentMadeId } = useParams();
const { formatMessage } = useIntl();
// Handle page title change in new and edit mode.
useEffect(() => {
if (paymentMadeId) {
changePageTitle(formatMessage({ id: 'edit_payment_made' }));
} else {
changePageTitle(formatMessage({ id: 'payment_made' }));
}
}, [changePageTitle, paymentMadeId, formatMessage]);
// Handle fetch accounts data.
const fetchAccounts = useQuery('accounts-list', (key) => const fetchAccounts = useQuery('accounts-list', (key) =>
requestFetchAccounts(), requestFetchAccounts(),
); );
// Handle fetch Items data table or list // Handle fetch Items data table or list.
const fetchItems = useQuery('items-list', () => requestFetchItems({})); const fetchItems = useQuery('items-list', () => requestFetchItems({}));
// Handle fetch venders data table or list // Handle fetch venders data table or list.
const fetchVenders = useQuery('venders-list', () => const fetchVenders = useQuery('venders-list', () =>
requestFetchVendorsTable({}), requestFetchVendorsTable({}),
); );
// Handle fetch specific payment made details.
const fetchPaymentMade = useQuery( const fetchPaymentMade = useQuery(
['payment-made', id], ['payment-made', paymentMadeId],
(key, _id) => requestFetchPaymentMade(_id), (key, _id) => requestFetchPaymentMade(_id),
{ enabled: !!id }, { enabled: !!paymentMadeId },
); );
// Fetch payment made settings.
const fetchSettings = useQuery(['settings'], () => requestFetchOptions({})); const fetchSettings = useQuery(['settings'], () => requestFetchOptions({}));
const handleFormSubmit = useCallback(
(payload) => {
payload.redirect && history.push('/payment-mades');
},
[history],
);
const handleCancel = useCallback(() => {
history.goBack();
}, [history]);
const handleVenderChange = (venderId) => {
setVenderId(venderId);
};
return ( return (
<DashboardInsider <DashboardInsider
loading={ loading={
fetchVenders.isFetching || fetchVenders.isFetching ||
fetchItems.isFetching || fetchItems.isFetching ||
fetchAccounts.isFetching || fetchAccounts.isFetching ||
fetchPaymentMade.isFetching fetchPaymentMade.isFetching ||
fetchSettings.isFetching
} }
name={'payment-made'} name={'payment-made'}
> >
<PaymentMadeForm <PaymentMadeForm
onFormSubmit={handleFormSubmit} paymentMadeId={paymentMadeId}
paymentMadeId={id} />
onCancelForm={handleCancel}
onVenderChange={handleVenderChange}
/>
</DashboardInsider> </DashboardInsider>
); );
} }
@@ -96,5 +94,7 @@ export default compose(
withAccountsActions, withAccountsActions,
withBillActions, withBillActions,
withPaymentMadeActions, withPaymentMadeActions,
withSettingsActions withSettingsActions,
withDashboardActions,
)(PaymentMade); )(PaymentMade);

View File

@@ -1,22 +1,38 @@
import React from 'react'; import React from 'react';
import { Intent, Button } from '@blueprintjs/core'; import { Intent, Button } from '@blueprintjs/core';
import { FormattedMessage as T } from 'react-intl'; import { FormattedMessage as T } from 'react-intl';
import classNames from 'classnames';
export default function PaymentMadeFormFooter({ import { CLASSES } from 'common/classes';
formik: { isSubmitting, resetForm },
/**
* Payment made floating actions bar.
*/
export default function PaymentMadeFloatingActions({
isSubmitting,
onSubmitClick, onSubmitClick,
onCancelClick, onCancelClick,
paymentMade, onClearBtnClick,
}) { }) {
const handleClearBtnClick = (event) => {
onClearBtnClick && onClearBtnClick(event);
};
const handleSubmitClick = (event) => {
onSubmitClick && onSubmitClick(event, { redirect: true });
};
const handleCancelClick = (event) => {
onCancelClick && onCancelClick(event);
};
return ( return (
<div className={'estimate-form__floating-footer'}> <div className={classNames(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
<Button <Button
disabled={isSubmitting} disabled={isSubmitting}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
type="submit" type="submit"
onClick={() => { onClick={handleSubmitClick}
onSubmitClick({ redirect: true });
}}
> >
<T id={'save_send'} /> <T id={'save_send'} />
</Button> </Button>
@@ -27,23 +43,23 @@ export default function PaymentMadeFormFooter({
className={'ml1'} className={'ml1'}
name={'save'} name={'save'}
type="submit" type="submit"
onClick={() => { onClick={handleSubmitClick}
onSubmitClick({ redirect: false });
}}
> >
<T id={'save'} /> <T id={'save'} />
</Button> </Button>
<Button className={'ml1'} disabled={isSubmitting} s> <Button
className={'ml1'}
disabled={isSubmitting}
onClick={handleClearBtnClick}
>
<T id={'clear'} /> <T id={'clear'} />
</Button> </Button>
<Button <Button
className={'ml1'} className={'ml1'}
type="submit" type="submit"
onClick={() => { onClick={handleCancelClick}
onCancelClick && onCancelClick();
}}
> >
<T id={'close'} /> <T id={'close'} />
</Button> </Button>

View File

@@ -1,96 +1,56 @@
import React, { import React, { useMemo, useState, useCallback } from 'react';
useMemo,
useCallback,
useEffect,
useState,
useRef,
} from 'react';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import moment from 'moment'; import moment from 'moment';
import { Intent, FormGroup, TextArea } from '@blueprintjs/core'; import { Intent, Alert } from '@blueprintjs/core';
import { useParams, useHistory } from 'react-router-dom';
import { FormattedMessage as T, useIntl } from 'react-intl'; import { FormattedMessage as T, useIntl } from 'react-intl';
import { pick, values } from 'lodash'; import { pick, sumBy } from 'lodash';
import classNames from 'classnames';
import { CLASSES } from 'common/classes';
import PaymentMadeHeader from './PaymentMadeFormHeader'; import PaymentMadeHeader from './PaymentMadeFormHeader';
import PaymentMadeItemsTable from './PaymentMadeItemsTable'; import PaymentMadeItemsTable from './PaymentMadeItemsTable';
import PaymentMadeFloatingActions from './PaymentMadeFloatingActions'; import PaymentMadeFloatingActions from './PaymentMadeFloatingActions';
import withDashboardActions from 'containers/Dashboard/withDashboardActions';
import withMediaActions from 'containers/Media/withMediaActions'; import withMediaActions from 'containers/Media/withMediaActions';
import withPaymentMadeActions from './withPaymentMadeActions'; import withPaymentMadeActions from './withPaymentMadeActions';
import withPaymentMadeDetail from './withPaymentMadeDetail'; import withPaymentMadeDetail from './withPaymentMadeDetail';
import withPaymentMade from './withPaymentMade'; import withPaymentMade from './withPaymentMade';
import { AppToaster } from 'components'; import { AppToaster } from 'components';
import Dragzone from 'components/Dragzone';
import useMedia from 'hooks/useMedia';
import { compose, repeatValue } from 'utils'; import { compose, orderingLinesIndexes } from 'utils';
import withSettings from 'containers/Settings/withSettings'; import withSettings from 'containers/Settings/withSettings';
import { useHistory } from 'react-router-dom';
const MIN_LINES_NUMBER = 5; const ERRORS = {
PAYMENT_NUMBER_NOT_UNIQUE: 'PAYMENT.NUMBER.NOT.UNIQUE',
};
/**
* Payment made form component.
*/
function PaymentMadeForm({ function PaymentMadeForm({
//#withMedia // #withMedia
requestSubmitMedia, requestSubmitMedia,
requestDeleteMedia, requestDeleteMedia,
//#withPaymentMadesActions // #withPaymentMadesActions
requestSubmitPaymentMade, requestSubmitPaymentMade,
requestEditPaymentMade, requestEditPaymentMade,
setPaymentNumberChange,
// #withPaymentMade // #withPaymentMadeDetail
nextPaymentNumberChanged,
// #withSettings
paymentNextNumber,
paymentNumberPrefix,
//#withDashboard
changePageTitle,
changePageSubtitle,
//#withPaymentMadeDetail
paymentMade, paymentMade,
onFormSubmit, paymentMadeId,
onCancelForm,
onVenderChange,
}) { }) {
const history = useHistory();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [payload, setPayload] = useState({});
const { id } = useParams();
const {
setFiles,
saveMedia,
deletedFiles,
setDeletedFiles,
deleteMedia,
} = useMedia({
saveCallback: requestSubmitMedia,
deleteCallback: requestDeleteMedia,
});
const savedMediaIds = useRef([]); const [amountChangeAlert, setAmountChangeAlert] = useState(false);
const clearSavedMediaIds = () => { const [clearLinesAlert, setClearLinesAlert] = useState(false);
savedMediaIds.current = []; const [clearFormAlert, setClearFormAlert] = useState(false);
}; const [fullAmount, setFullAmount] = useState(null);
useEffect(() => {
onVenderChange && onVenderChange(formik.values.vendor_id);
});
useEffect(() => {
if (paymentMade && paymentMade.id) {
changePageTitle(formatMessage({ id: 'edit_payment_made' }));
} else {
changePageTitle(formatMessage({ id: 'payment_made' }));
}
}, [changePageTitle, paymentMade, formatMessage]);
// Yup validation schema.
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
vendor_id: Yup.string() vendor_id: Yup.string()
.label(formatMessage({ id: 'vendor_name_' })) .label(formatMessage({ id: 'vendor_name_' }))
@@ -108,11 +68,9 @@ function PaymentMadeForm({
description: Yup.string(), description: Yup.string(),
entries: Yup.array().of( entries: Yup.array().of(
Yup.object().shape({ Yup.object().shape({
payment_amount: Yup.number().nullable(), id: Yup.number().nullable(),
bill_number: Yup.number().nullable(),
amount: Yup.number().nullable(),
due_amount: Yup.number().nullable(), due_amount: Yup.number().nullable(),
bill_date: Yup.date(), payment_amount: Yup.number().nullable().max(Yup.ref('due_amount')),
bill_id: Yup.number() bill_id: Yup.number()
.nullable() .nullable()
.when(['payment_amount'], { .when(['payment_amount'], {
@@ -122,194 +80,260 @@ function PaymentMadeForm({
}), }),
), ),
}); });
const handleDropFiles = useCallback((_files) => {
setFiles(_files.filter((file) => file.uploaded === false));
}, []);
const savePaymentMadeSubmit = useCallback((payload) => { // Default payment made entry values.
onFormSubmit && onFormSubmit(payload); const defaultPaymentMadeEntry = useMemo(
}); () => ({ bill_id: '', payment_amount: '', id: null }),
const defaultPaymentMade = useMemo(
() => ({
bill_id: '',
bill_date: moment(new Date()).format('YYYY-MM-DD'),
bill_number: '',
amount: '',
due_amount: '',
payment_amount: '',
}),
[], [],
); );
const paymentNumber = paymentNumberPrefix // Default initial values.
? `${paymentNumberPrefix}-${paymentNextNumber}`
: paymentNextNumber;
const defaultInitialValues = useMemo( const defaultInitialValues = useMemo(
() => ({ () => ({
full_amount: '',
vendor_id: '', vendor_id: '',
payment_account_id: '', payment_account_id: '',
payment_date: moment(new Date()).format('YYYY-MM-DD'), payment_date: moment(new Date()).format('YYYY-MM-DD'),
reference: '', reference: '',
payment_number: paymentNumber, payment_number: '',
// receive_amount: '',
description: '', description: '',
entries: [...repeatValue(defaultPaymentMade, MIN_LINES_NUMBER)], entries: [],
}), }),
[defaultPaymentMade], [],
); );
const orderingIndex = (_entries) => { // Form initial values.
return _entries.map((item, index) => ({
...item,
index: index + 1,
}));
};
const initialValues = useMemo( const initialValues = useMemo(
() => ({ () => ({
...(paymentMade ...(paymentMade
? { ? {
...pick(paymentMade, Object.keys(defaultInitialValues)), ...pick(paymentMade, Object.keys(defaultInitialValues)),
full_amount: sumBy(paymentMade.entries, 'payment_amount'),
entries: [ entries: [
...paymentMade.entries.map((paymentMade) => ({ ...paymentMade.entries.map((paymentMadeEntry) => ({
...pick(paymentMade, Object.keys(defaultPaymentMade)), ...pick(paymentMadeEntry, Object.keys(defaultPaymentMadeEntry)),
})), })),
...repeatValue(
defaultPaymentMade,
Math.max(MIN_LINES_NUMBER - paymentMade.entries.length, 0),
),
], ],
} }
: { : {
...defaultInitialValues, ...defaultInitialValues,
entries: orderingIndex(defaultInitialValues.entries), entries: orderingLinesIndexes(defaultInitialValues.entries),
}), }),
}), }),
[paymentMade, defaultInitialValues, defaultPaymentMade], [paymentMade, defaultInitialValues, defaultPaymentMadeEntry],
); );
const initialAttachmentFiles = useMemo(() => { const handleSubmitForm = (
return paymentMade && paymentMade.media values,
? paymentMade.media.map((attach) => ({ { setSubmitting, resetForm, setFieldError },
preview: attach.attachment_file, ) => {
uploaded: true, setSubmitting(true);
metadata: { ...attach },
}))
: [];
}, [paymentMade]);
const formik = useFormik({ // Filters entries that have no `bill_id` or `payment_amount`.
validationSchema, const entries = values.entries.filter((item) => {
initialValues: { return !item.bill_id || item.payment_amount;
...initialValues, });
}, // Total payment amount of entries.
onSubmit: async (values, { setSubmitting, setErrors, resetForm }) => { const totalPaymentAmount = sumBy(entries, 'payment_amount');
setSubmitting(true);
const entries = formik.values.entries.filter((item) => { if (totalPaymentAmount <= 0) {
if (item.bill_id !== undefined) { AppToaster.show({
return { ...item }; message: formatMessage({
} id: 'you_cannot_make_payment_with_zero_total_amount',
intent: Intent.WARNING,
}),
}); });
const form = { return;
...values, }
entries, const form = { ...values, entries };
};
const requestForm = { ...form }; // Triggers once the save request success.
const onSaved = (response) => {
AppToaster.show({
message: formatMessage({
id: paymentMadeId
? 'the_payment_made_has_been_successfully_edited'
: 'the_payment_made_has_been_successfully_created',
}),
intent: Intent.SUCCESS,
});
setSubmitting(false);
resetForm();
};
if (paymentMade && paymentMade.id) { const onError = (errors) => {
requestEditPaymentMade(paymentMade.id, requestForm) const getError = (errorType) => errors.find((e) => e.type === errorType);
.then((response) => {
AppToaster.show({ if (getError(ERRORS.PAYMENT_NUMBER_NOT_UNIQUE)) {
message: formatMessage({ setFieldError(
id: 'the_payment_made_has_been_successfully_edited', 'payment_number',
}), formatMessage({ id: 'payment_number_is_not_unique' }),
intent: Intent.SUCCESS, );
});
setSubmitting(false);
savePaymentMadeSubmit({ action: 'update', ...payload });
resetForm();
})
.catch((error) => {
setSubmitting(false);
});
} else {
requestSubmitPaymentMade(requestForm)
.then((response) => {
AppToaster.show({
message: formatMessage({
id: 'the_payment_made_has_been_successfully_created',
}),
intent: Intent.SUCCESS,
});
setSubmitting(false);
resetForm();
savePaymentMadeSubmit({ action: 'new', ...payload });
})
.catch((errors) => {
setSubmitting(false);
});
} }
}, setSubmitting(false);
};
if (paymentMade && paymentMade.id) {
requestEditPaymentMade(paymentMade.id, form).then(onSaved).catch(onError);
} else {
requestSubmitPaymentMade(form).then(onSaved).catch(onError);
}
};
const {
errors,
touched,
setFieldValue,
getFieldProps,
setValues,
values,
handleSubmit,
isSubmitting,
} = useFormik({
validationSchema,
initialValues,
onSubmit: handleSubmitForm,
}); });
const handleDeleteFile = useCallback( const handleFullAmountChange = useCallback(
(_deletedFiles) => { (value) => {
_deletedFiles.forEach((deletedFile) => { if (value !== fullAmount) {
if (deletedFile.upload && deletedFile.metadata.id) { setAmountChangeAlert(value);
setDeletedFiles([...deletedFiles, deletedFile.metadata.id]); }
}
});
}, },
[setDeletedFiles, deletedFiles], [fullAmount, setAmountChangeAlert],
); );
const handleSubmitClick = useCallback( // Handle cancel button of amount change alert.
(payload) => { const handleCancelAmountChangeAlert = () => {
setPayload(payload); setAmountChangeAlert(false);
formik.submitForm();
},
[setPayload, formik],
);
const handleCancelClick = useCallback(
(payload) => {
onCancelForm && onCancelForm(payload);
},
[onCancelForm],
);
const handleClickAddNewRow = () => {
formik.setFieldValue(
'entries',
orderingIndex([...formik.values.entries, defaultPaymentMade]),
);
}; };
// Handle confirm button of amount change alert.
const handleConfirmAmountChangeAlert = () => {
setFullAmount(amountChangeAlert);
setAmountChangeAlert(false);
};
// Handle update data.
const handleUpdataData = useCallback(
(entries) => {
setFieldValue('entries', entries);
},
[setFieldValue],
);
// Handle cancel button click.
const handleCancelClick = useCallback(() => {
history.push('/payment-mades');
}, [history]);
// Handle clear all lines button click.
const handleClearAllLines = () => { const handleClearAllLines = () => {
formik.setFieldValue( setClearLinesAlert(true);
'entries',
orderingIndex([...repeatValue(defaultPaymentMade, MIN_LINES_NUMBER)]),
);
}; };
useEffect(() => { const handleCancelClearLines = useCallback(() => {
formik.setFieldValue('payment_number', paymentNumber); setClearLinesAlert(false);
setPaymentNumberChange(false); }, [setClearLinesAlert]);
}, [nextPaymentNumberChanged, paymentNumber]);
const handleConfirmClearLines = useCallback(() => {
setFieldValue(
'entries',
values.entries.map((entry) => ({
...entry,
payment_amount: 0,
})),
);
setClearLinesAlert(false);
}, [setFieldValue, setClearLinesAlert, values.entries]);
// Handle clear button click.
const handleClearBtnClick = useCallback(() => {
setClearFormAlert(true);
}, []);
//
const handleCancelClearFormAlert = () => {
setClearFormAlert(false);
};
const handleConfirmCancelClearFormAlert = () => {
setValues({
...defaultInitialValues,
...(paymentMadeId
? {
vendor_id: values.vendor_id,
payment_number: values.payment_number,
}
: {}),
});
setClearFormAlert(false);
};
return ( return (
<div className={'payment_made_form'}> <div
<form onSubmit={formik.handleSubmit}> className={classNames(CLASSES.PAGE_FORM, CLASSES.PAGE_FORM_PAYMENT_MADE)}
<PaymentMadeHeader formik={formik} /> >
<form onSubmit={handleSubmit}>
<PaymentMadeHeader
paymentMadeId={paymentMadeId}
vendorId={values.vendor_id}
errors={errors}
touched={touched}
setFieldValue={setFieldValue}
getFieldProps={getFieldProps}
values={values}
onFullAmountChanged={handleFullAmountChange}
/>
<PaymentMadeItemsTable <PaymentMadeItemsTable
formik={formik} fullAmount={fullAmount}
entries={formik.values.entries} paymentEntries={values.entries}
vendor_id={formik.values.vendor_id} vendorId={values.vendor_id}
onClickAddNewRow={handleClickAddNewRow} paymentMadeId={paymentMadeId}
onUpdateData={handleUpdataData}
onClickClearAllLines={handleClearAllLines} onClickClearAllLines={handleClearAllLines}
errors={errors?.entries}
/>
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={<T id={'ok'} />}
intent={Intent.WARNING}
isOpen={amountChangeAlert}
onCancel={handleCancelAmountChangeAlert}
onConfirm={handleConfirmAmountChangeAlert}
>
<p>Are you sure to discard full amount?</p>
</Alert>
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={<T id={'ok'} />}
intent={Intent.WARNING}
isOpen={clearLinesAlert}
onCancel={handleCancelClearLines}
onConfirm={handleConfirmClearLines}
>
<p>Are you sure to discard full amount?</p>
</Alert>
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={<T id={'ok'} />}
intent={Intent.WARNING}
isOpen={clearFormAlert}
onCancel={handleCancelClearFormAlert}
onConfirm={handleConfirmCancelClearFormAlert}
>
<p>Are you sure to clear form data.</p>
</Alert>
<PaymentMadeFloatingActions
isSubmitting={isSubmitting}
onCancelClick={handleCancelClick}
onClearBtnClick={handleClearBtnClick}
/> />
{/* <Dragzone {/* <Dragzone
@@ -319,18 +343,12 @@ function PaymentMadeForm({
hint={'Attachments: Maxiumum size: 20MB'} hint={'Attachments: Maxiumum size: 20MB'}
/> */} /> */}
</form> </form>
<PaymentMadeFloatingActions
formik={formik}
onSubmitClick={handleSubmitClick}
onCancel={handleCancelClick}
/>
</div> </div>
); );
} }
export default compose( export default compose(
withPaymentMadeActions, withPaymentMadeActions,
withDashboardActions,
withMediaActions, withMediaActions,
withPaymentMadeDetail(), withPaymentMadeDetail(),
withPaymentMade(({ nextPaymentNumberChanged }) => ({ withPaymentMade(({ nextPaymentNumberChanged }) => ({

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useCallback, useState } from 'react'; import React, { useMemo, useCallback } from 'react';
import { import {
FormGroup, FormGroup,
InputGroup, InputGroup,
@@ -7,38 +7,59 @@ import {
MenuItem, MenuItem,
Classes, Classes,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { sumBy } from 'lodash';
import { DateInput } from '@blueprintjs/datetime'; import { DateInput } from '@blueprintjs/datetime';
import { FormattedMessage as T } from 'react-intl'; import { FormattedMessage as T } from 'react-intl';
import { useParams, useHistory } from 'react-router-dom';
import moment from 'moment'; import moment from 'moment';
import { momentFormatter, compose, tansformDateValue } from 'utils';
import classNames from 'classnames'; import classNames from 'classnames';
import { CLASSES } from 'common/classes';
import { momentFormatter, compose, tansformDateValue } from 'utils';
import { import {
AccountsSelectList, AccountsSelectList,
ListSelect, ListSelect,
ErrorMessage, ErrorMessage,
FieldRequiredHint, FieldRequiredHint,
Icon, Money,
InputPrependButton, Hint,
} from 'components'; } from 'components';
import withBills from '../Bill/withBills';
import withVender from 'containers/Vendors/withVendors'; import withVender from 'containers/Vendors/withVendors';
import withAccounts from 'containers/Accounts/withAccounts'; import withAccounts from 'containers/Accounts/withAccounts';
import withDialogActions from 'containers/Dialog/withDialogActions';
/**
* Payment made header form.
*/
function PaymentMadeFormHeader({ function PaymentMadeFormHeader({
formik: { errors, touched, setFieldValue, getFieldProps, values }, paymentMadeId,
vendorId,
// #useFormik
errors,
touched,
setFieldValue,
getFieldProps,
values,
onFullAmountChanged,
//#withVender //#withVender
vendorsCurrentPage, vendorsCurrentPage,
vendorItems, vendorItems,
//#withAccouts //#withAccouts
accountsList, accountsList,
// #withDialogActions
openDialog, // #withBills
vendorPayableBills,
}) { }) {
const isNewMode = !paymentMadeId;
const payableFullAmount = useMemo(
() => sumBy(vendorPayableBills, 'due_amount'),
[vendorPayableBills],
);
const handleDateChange = useCallback( const handleDateChange = useCallback(
(date_filed) => (date) => { (date_filed) => (date) => {
const formatted = moment(date).format('YYYY-MM-DD'); const formatted = moment(date).format('YYYY-MM-DD');
@@ -58,6 +79,14 @@ function PaymentMadeFormHeader({
[], [],
); );
const triggerFullAmountChanged = (value) => {
onFullAmountChanged && onFullAmountChanged(value);
}
const handleFullAmountBlur = (event) => {
triggerFullAmountChanged(event.currentTarget.value);
};
const handleFilterVender = (query, vender, index, exactMatch) => { const handleFilterVender = (query, vender, index, exactMatch) => {
const normalizedTitle = vender.display_name.toLowerCase(); const normalizedTitle = vender.display_name.toLowerCase();
const normalizedQuery = query.toLowerCase(); const normalizedQuery = query.toLowerCase();
@@ -85,14 +114,15 @@ function PaymentMadeFormHeader({
[accountsList], [accountsList],
); );
const handlePaymentNumberChange = useCallback(() => { const handleReceiveFullAmountClick = () => {
openDialog('payment-number-form', {}); setFieldValue('full_amount', payableFullAmount);
}, [openDialog]); triggerFullAmountChanged(payableFullAmount);
};
return ( return (
<div> <div className={classNames(CLASSES.PAGE_FORM_HEADER)}>
<div> <div className={classNames(CLASSES.PAGE_FORM_HEADER_PRIMARY)}>
{/* Vendor name */} {/* ------------ Vendor name ------------ */}
<FormGroup <FormGroup
label={<T id={'vendor_name'} />} label={<T id={'vendor_name'} />}
inline={true} inline={true}
@@ -114,10 +144,12 @@ function PaymentMadeFormHeader({
selectedItemProp={'id'} selectedItemProp={'id'}
defaultText={<T id={'select_vender_account'} />} defaultText={<T id={'select_vender_account'} />}
labelProp={'display_name'} labelProp={'display_name'}
buttonProps={{ disabled: !isNewMode }}
disabled={!isNewMode}
/> />
</FormGroup> </FormGroup>
{/* Payment date */} {/* ------------ Payment date ------------ */}
<FormGroup <FormGroup
label={<T id={'payment_date'} />} label={<T id={'payment_date'} />}
inline={true} inline={true}
@@ -136,7 +168,35 @@ function PaymentMadeFormHeader({
/> />
</FormGroup> </FormGroup>
{/* payment number */} {/* ------------ Full amount ------------ */}
<FormGroup
label={<T id={'full_amount'} />}
inline={true}
className={('form-group--full-amount', Classes.FILL)}
intent={
errors.full_amount && touched.full_amount && Intent.DANGER
}
labelInfo={<Hint />}
helperText={
<ErrorMessage name="full_amount" {...{ errors, touched }} />
}
>
<InputGroup
intent={
errors.full_amount && touched.full_amount && Intent.DANGER
}
minimal={true}
value={values.full_amount}
{...getFieldProps('full_amount')}
onBlur={handleFullAmountBlur}
/>
<a onClick={handleReceiveFullAmountClick} href="#" className={'receive-full-amount'}>
Receive full amount (<Money amount={payableFullAmount} currency={'USD'} />)
</a>
</FormGroup>
{/* ------------ Payment number ------------ */}
<FormGroup <FormGroup
label={<T id={'payment_no'} />} label={<T id={'payment_no'} />}
inline={true} inline={true}
@@ -154,25 +214,11 @@ function PaymentMadeFormHeader({
errors.payment_number && touched.payment_number && Intent.DANGER errors.payment_number && touched.payment_number && Intent.DANGER
} }
minimal={true} minimal={true}
rightElement={
<InputPrependButton
buttonProps={{
onClick: handlePaymentNumberChange,
icon: <Icon icon={'settings-18'} />,
}}
tooltip={true}
tooltipProps={{
content: 'Setting your auto-generated payment number',
position: Position.BOTTOM_LEFT,
}}
/>
}
minimal={true}
{...getFieldProps('payment_number')} {...getFieldProps('payment_number')}
/> />
</FormGroup> </FormGroup>
{/* payment account */} {/* ------------ Payment account ------------ */}
<FormGroup <FormGroup
label={<T id={'payment_account'} />} label={<T id={'payment_account'} />}
className={classNames( className={classNames(
@@ -192,7 +238,7 @@ function PaymentMadeFormHeader({
name={'payment_account_id'} name={'payment_account_id'}
{...{ errors, touched }} {...{ errors, touched }}
/> />
} }
> >
<AccountsSelectList <AccountsSelectList
accounts={paymentAccounts} accounts={paymentAccounts}
@@ -202,22 +248,22 @@ function PaymentMadeFormHeader({
selectedAccountId={values.payment_account_id} selectedAccountId={values.payment_account_id}
/> />
</FormGroup> </FormGroup>
</div>
{/* reference */} {/* ------------ Reference ------------ */}
<FormGroup <FormGroup
label={<T id={'reference'} />} label={<T id={'reference'} />}
inline={true} inline={true}
className={classNames('form-group--reference', Classes.FILL)} className={classNames('form-group--reference', Classes.FILL)}
intent={errors.reference && touched.reference && Intent.DANGER}
helperText={<ErrorMessage name="reference" {...{ errors, touched }} />}
>
<InputGroup
intent={errors.reference && touched.reference && Intent.DANGER} intent={errors.reference && touched.reference && Intent.DANGER}
minimal={true} helperText={<ErrorMessage name="reference" {...{ errors, touched }} />}
{...getFieldProps('reference')} >
/> <InputGroup
</FormGroup> intent={errors.reference && touched.reference && Intent.DANGER}
minimal={true}
{...getFieldProps('reference')}
/>
</FormGroup>
</div>
</div> </div>
); );
} }
@@ -230,5 +276,7 @@ export default compose(
withAccounts(({ accountsList }) => ({ withAccounts(({ accountsList }) => ({
accountsList, accountsList,
})), })),
withDialogActions, withBills(({ vendorPayableBills }) => ({
vendorPayableBills,
})),
)(PaymentMadeFormHeader); )(PaymentMadeFormHeader);

View File

@@ -1,234 +1,137 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button, Intent, Position, Tooltip } from '@blueprintjs/core'; import { useQuery } from 'react-query';
import { FormattedMessage as T, useIntl } from 'react-intl'; import { CloudLoadingIndicator } from 'components'
import { Icon, DataTable } from 'components'; import PaymentMadeItemsTableEditor from './PaymentMadeItemsTableEditor';
import moment from 'moment';
import { compose, formattedAmount, transformUpdatedRows } from 'utils';
import {
MoneyFieldCell,
DivFieldCell,
EmptyDiv,
} from 'components/DataTableCells';
import withPaymentMadeActions from './withPaymentMadeActions';
import withBillActions from '../Bill/withBillActions';
import withBills from '../Bill/withBills'; import withBills from '../Bill/withBills';
import { omit, pick } from 'lodash';
const ActionsCellRenderer = ({ import { compose } from 'utils';
row: { index },
column: { id },
cell: { value },
data,
payload,
}) => {
if (data.length <= index + 1) {
return '';
}
const onRemoveRole = () => {
payload.removeRow(index);
};
return (
<Tooltip content={<T id={'remove_the_line'} />} position={Position.LEFT}>
<Button
icon={<Icon icon={'times-circle'} iconSize={14} />}
iconSize={14}
className="m12"
intent={Intent.DANGER}
onClick={onRemoveRole}
/>
</Tooltip>
);
};
const CellRenderer = (content, type) => (props) => {
if (props.data.length === props.row.index + 1) {
return '';
}
return content(props);
};
const TotalCellRederer = (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 <span>{formattedAmount(total, 'USD')}</span>;
}
return content(props);
};
/**
* Payment made items table.
*/
function PaymentMadeItemsTable({ function PaymentMadeItemsTable({
//#ownProps // #ownProps
onClickRemoveRow, paymentMadeId,
onClickAddNewRow, vendorId,
fullAmount,
onUpdateData,
paymentEntries = [], // { bill_id: number, payment_amount: number, id?: number }
onClickClearAllLines, onClickClearAllLines,
entries, errors,
formik: { errors, setFieldValue, values },
// #withBillActions
requestFetchDueBills,
// #withBills
vendorPayableBills,
paymentMadePayableBills,
// #withPaymentMadeDetail
paymentMade,
}) { }) {
const [rows, setRows] = useState([]); const [tableData, setTableData] = useState([]);
const [entrie, setEntrie] = useState([]); const [localAmount, setLocalAmount] = useState(fullAmount);
const { formatMessage } = useIntl();
// Payable bills based on selected vendor or specific payment made.
const payableBills = useMemo(
() =>
paymentMadeId
? paymentMadePayableBills
: vendorId
? vendorPayableBills
: [],
[paymentMadeId, paymentMadePayableBills, vendorId, vendorPayableBills],
);
const isNewMode = !paymentMadeId;
const triggerUpdateData = useCallback((data) => {
onUpdateData && onUpdateData(data);
}, [onUpdateData]);
// Merges payment entries with payable bills.
const computedTableData = useMemo(() => {
const entriesTable = new Map(
paymentEntries.map((e) => [e.bill_id, e]),
);
return payableBills.map((bill) => {
const entry = entriesTable.get(bill.id);
return {
...bill,
bill_id: bill.id,
bill_payment_amount: bill.payment_amount,
payment_amount: entry ? entry.payment_amount : 0,
id: entry ? entry.id : null,
}
});
}, [paymentEntries, payableBills]);
useEffect(() => { useEffect(() => {
setRows([...entries.map((e) => ({ ...e }))]); setTableData(computedTableData);
}, [entries]); }, [computedTableData]);
const columns = useMemo( // Handle mapping `fullAmount` prop to `localAmount` state.
() => [ useEffect(() => {
{ if (localAmount !== fullAmount) {
Header: '#', let _fullAmount = fullAmount;
accessor: 'index', const newTableData = tableData.map((data) => {
Cell: ({ row: { index } }) => <span>{index + 1}</span>, const amount = Math.min(data.due_amount, _fullAmount);
width: 40, _fullAmount -= Math.max(amount, 0);
disableResizing: true,
disableSortBy: true,
},
{
Header: formatMessage({ id: 'Date' }),
id: 'bill_date',
accessor: (r) => moment(r.bill_date).format('YYYY MMM DD'),
Cell: CellRenderer(EmptyDiv, 'bill_date'),
disableSortBy: true,
disableResizing: true,
width: 250,
},
{ return {
Header: formatMessage({ id: 'bill_number' }), ...data,
accessor: (row) => `#${row.bill_number}`, payment_amount: amount,
Cell: CellRenderer(EmptyDiv, 'bill_number'), };
disableSortBy: true,
className: 'bill_number',
},
{
Header: formatMessage({ id: 'bill_amount' }),
accessor: 'amount',
Cell: CellRenderer(DivFieldCell, 'amount'),
disableSortBy: true,
width: 100,
className: '',
},
{
Header: formatMessage({ id: 'amount_due' }),
accessor: 'due_amount',
Cell: TotalCellRederer(DivFieldCell, 'due_amount'),
disableSortBy: true,
width: 150,
className: '',
},
{
Header: formatMessage({ id: 'payment_amount' }),
accessor: 'payment_amount',
Cell: TotalCellRederer(MoneyFieldCell, 'payment_amount'),
disableSortBy: true,
width: 150,
className: '',
},
{
Header: '',
accessor: 'action',
Cell: ActionsCellRenderer,
className: 'actions',
disableSortBy: true,
disableResizing: true,
width: 45,
},
],
[formatMessage],
);
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);
},
[entrie, setFieldValue, onClickRemoveRow],
);
const onClickNewRow = () => {
onClickAddNewRow && onClickAddNewRow();
};
const handleClickClearAllLines = () => {
onClickClearAllLines && onClickClearAllLines();
};
const rowClassNames = useCallback((row) => {
return { 'row--total': rows.length === row.index + 1 };
});
const handleUpdateData = useCallback(
(rowIndex, columnId, value) => {
const newRows = rows.map((row, index) => {
if (index === rowIndex) {
return {
...rows[rowIndex],
[columnId]: value,
};
}
return row;
}); });
// setRows(newRows); setTableData(newTableData);
setFieldValue( setLocalAmount(fullAmount);
'entries', triggerUpdateData(newTableData);
newRows, }
// .map((row) => ({ }, [
// ...pick(row, ['payment_amount']), tableData,
// invoice_id: row.id, setTableData,
// })), setLocalAmount,
); triggerUpdateData,
}, localAmount,
[rows, setFieldValue, setRows], fullAmount,
); ]);
return (
<div className={'estimate-form__table'}>
<DataTable
columns={columns}
data={rows}
rowClassNames={rowClassNames}
spinnerProps={false}
payload={{
errors: errors.entries || [],
updateData: handleUpdateData,
removeRow: handleRemoveRow,
}}
/>
<div className={'mt1'}>
<Button
small={true}
className={'button--secondary button--new-line'}
onClick={onClickNewRow}
>
<T id={'new_lines'} />
</Button>
<Button // Fetches vendor due bills.
small={true} const fetchVendorDueBills = useQuery(
className={'button--secondary button--clear-lines ml1'} ['vendor-due-bills', vendorId],
onClick={handleClickClearAllLines} (key, _vendorId) => requestFetchDueBills(_vendorId),
> { enabled: isNewMode && vendorId },
<T id={'clear_all_lines'} /> );
</Button>
</div> // Handle update data.
</div> const handleUpdateData = (rows) => {
triggerUpdateData(rows);
};
const noResultsMessage = (vendorId) ?
'There is no payable bills for this vendor that can be applied for this payment' :
'Please select a vendor to display all open bills for it.';
return (
<CloudLoadingIndicator isLoading={fetchVendorDueBills.isFetching}>
<PaymentMadeItemsTableEditor
noResultsMessage={noResultsMessage}
data={tableData}
errors={errors}
onUpdateData={handleUpdateData}
onClickClearAllLines={onClickClearAllLines}
/>
</CloudLoadingIndicator>
); );
} }
export default compose()(PaymentMadeItemsTable); export default compose(
// withBills(({}) => ({})) withPaymentMadeActions,
withBillActions,
withBills(({ vendorPayableBills, paymentMadePayableBills }) => ({
vendorPayableBills,
paymentMadePayableBills,
})),
)(PaymentMadeItemsTable);

View File

@@ -0,0 +1,165 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from '@blueprintjs/core';
import { FormattedMessage as T, useIntl } from 'react-intl';
import moment from 'moment';
import { sumBy } from 'lodash';
import classNames from 'classnames';
import { CLASSES } from 'common/classes';
import { DataTable, Money } from 'components';
import { transformUpdatedRows } from 'utils';
import {
MoneyFieldCell,
DivFieldCell,
EmptyDiv,
} from 'components/DataTableCells';
/**
* Cell renderer guard.
*/
const CellRenderer = (content, type) => (props) => {
if (props.data.length === props.row.index + 1) {
return '';
}
return content(props);
};
const TotalCellRederer = (content, type) => (props) => {
if (props.data.length === props.row.index + 1) {
return <Money amount={props.cell.row.original[type]} currency={'USD'} />
}
return content(props);
};
/**
* Payment made items editor table.
*/
export default function PaymentMadeItemsTableEditor({
//#ownProps
onClickClearAllLines,
onUpdateData,
data,
errors,
noResultsMessage
}) {
const transformedData = useMemo(() => {
const rows = data;
const totalRow = {
due_amount: sumBy(data, 'due_amount'),
payment_amount: sumBy(data, 'payment_amount'),
};
if (rows.length > 0) { rows.push(totalRow) }
return rows;
}, [data]);
const [localData, setLocalData] = useState(transformedData);
const { formatMessage } = useIntl();
useEffect(() => {
if (localData !== transformedData) {
setLocalData(transformedData);
}
}, [setLocalData, localData, transformedData]);
const columns = useMemo(
() => [
{
Header: '#',
accessor: 'index',
Cell: ({ row: { index } }) => <span>{index + 1}</span>,
width: 40,
disableResizing: true,
disableSortBy: true,
},
{
Header: formatMessage({ id: 'Date' }),
id: 'bill_date',
accessor: (r) => moment(r.bill_date).format('YYYY MMM DD'),
Cell: CellRenderer(EmptyDiv, 'bill_date'),
disableSortBy: true,
},
{
Header: formatMessage({ id: 'bill_number' }),
accessor: (row) => `#${row.bill_number}`,
Cell: CellRenderer(EmptyDiv, 'bill_number'),
disableSortBy: true,
className: 'bill_number',
},
{
Header: formatMessage({ id: 'bill_amount' }),
accessor: 'amount',
Cell: CellRenderer(DivFieldCell, 'amount'),
disableSortBy: true,
className: '',
},
{
Header: formatMessage({ id: 'amount_due' }),
accessor: 'due_amount',
Cell: TotalCellRederer(DivFieldCell, 'due_amount'),
disableSortBy: true,
className: '',
},
{
Header: formatMessage({ id: 'payment_amount' }),
accessor: 'payment_amount',
Cell: TotalCellRederer(MoneyFieldCell, 'payment_amount'),
disableSortBy: true,
className: '',
},
],
[formatMessage],
);
// Handle click clear all lines button.
const handleClickClearAllLines = () => {
onClickClearAllLines && onClickClearAllLines();
};
const rowClassNames = useCallback(
(row) => ({ 'row--total': localData.length === row.index + 1 }),
[localData],
);
// Handle update data.
const handleUpdateData = useCallback(
(rowIndex, columnId, value) => {
const newRows = transformUpdatedRows(
localData,
rowIndex,
columnId,
value,
);
setLocalData(newRows);
onUpdateData && onUpdateData(newRows);
},
[localData, setLocalData, onUpdateData],
);
return (
<div className={classNames(
CLASSES.DATATABLE_EDITOR,
CLASSES.DATATABLE_EDITOR_ITEMS_ENTRIES,
)}>
<DataTable
columns={columns}
data={localData}
rowClassNames={rowClassNames}
spinnerProps={false}
payload={{
errors,
updateData: handleUpdateData,
}}
noResults={noResultsMessage}
/>
<div className={classNames(CLASSES.DATATABLE_EDITOR_ACTIONS, 'mt1')}>
<Button
small={true}
className={'button--secondary button--clear-lines'}
onClick={handleClickClearAllLines}
>
<T id={'clear_all_lines'} />
</Button>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import {
deletePaymentMade, deletePaymentMade,
fetchPaymentMadesTable, fetchPaymentMadesTable,
fetchPaymentMade, fetchPaymentMade,
fetchPaymentMadeBills,
} from 'store/PaymentMades/paymentMade.actions'; } from 'store/PaymentMades/paymentMade.actions';
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
@@ -16,6 +17,8 @@ const mapDispatchToProps = (dispatch) => ({
requestFetchPaymentMadesTable: (query = {}) => requestFetchPaymentMadesTable: (query = {}) =>
dispatch(fetchPaymentMadesTable({ query: { ...query } })), dispatch(fetchPaymentMadesTable({ query: { ...query } })),
requestFetchPaymentMadeBills: (paymentMadeId) => dispatch(fetchPaymentMadeBills({ paymentMadeId })),
changePaymentMadeView: (id) => changePaymentMadeView: (id) =>
dispatch({ dispatch({
type: t.PAYMENT_MADE_SET_CURRENT_VIEW, type: t.PAYMENT_MADE_SET_CURRENT_VIEW,

View File

@@ -123,11 +123,19 @@ export const fetchDueBills = ({ vendorId }) => (dispatch) => new Promise((resolv
ApiService.get(`purchases/bills/due`, { params }).then((response) => { ApiService.get(`purchases/bills/due`, { params }).then((response) => {
dispatch({ dispatch({
type: t.DUE_BILLS_SET, type: t.BILLS_ITEMS_SET,
payload: { payload: {
bills: response.data.bills, bills: response.data.bills,
} },
}); });
if ( vendorId ) {
dispatch({
type: t.BILLS_PAYABLE_BY_VENDOR_ID,
payload: {
bills: response.data.bills,
}
});
}
resolve(response); resolve(response);
}).catch(error => { reject(error) }); }).catch(error => { reject(error) });
}); });

View File

@@ -13,7 +13,10 @@ const initialState = {
page: 1, page: 1,
}, },
nextBillNumberChanged: false, nextBillNumberChanged: false,
dueBills: {}, payable: {
byVendorId: [],
byBillPayamentId: [],
},
}; };
const defaultBill = { const defaultBill = {
@@ -105,23 +108,31 @@ const reducer = createReducer(initialState, {
state.nextBillNumberChanged = isChanged; state.nextBillNumberChanged = isChanged;
}, },
[t.DUE_BILLS_SET]: (state, action) => { [t.BILLS_PAYABLE_BY_VENDOR_ID]: (state, action) => {
const { bills } = action.payload; const { bills } = action.payload;
const _data = {};
const _dueBills = { ...state.dueBills };
const _bills = { ...state.items };
bills.forEach((bill) => { bills.forEach((bill) => {
_bills[bill.id] = { ...bill }; if (!_data[bill.vendor_id]) {
_data[bill.vendor_id] = [];
if (!_dueBills[bill.vendor_id]) {
_dueBills[bill.vendor_id] = []
} }
_dueBills[bill.vendor_id].push(bill.id); _data[bill.vendor_id].push(bill.id);
}); });
state.items = { ..._bills }; state.payable.byVendorId = {
state.dueBills = { ..._dueBills }; ...state.payable.byVendorId,
..._data,
};
},
[t.BILLS_PAYABLE_BY_PAYMENT_ID]: (state, action) => {
const { bills, billPaymentId } = action.payload;
const _data = [];
bills.forEach((bill) => {
_data.push(bill.id);
});
state.payable.byBillPayamentId[billPaymentId] = _data;
} }
}); });

View File

@@ -19,7 +19,10 @@ const billByIdSelector = (state, props) => state.bills.items[props.billId];
* Retrieve vendor due bills ids. * Retrieve vendor due bills ids.
* @return {number[]} * @return {number[]}
*/ */
const billsDueVendorSelector = (state, props) => state.bills.dueBills[props.vendorId]; const billsPayableVendorSelector = (state, props) => state.bills.payable.byVendorId[props.vendorId];
const billsPayableByPaymentMadeSelector = (state, props) => {
return state.bills.payable.byBillPayamentId[props.paymentMadeId];
}
const billPaginationSelector = (state, props) => { const billPaginationSelector = (state, props) => {
const viewId = state.bills.currentViewId; const viewId = state.bills.currentViewId;
@@ -62,16 +65,26 @@ export const getBillPaginationMetaFactory = () =>
return billPage?.paginationMeta || {}; return billPage?.paginationMeta || {};
}); });
/**
* Retrieve due bills of specific vendor. export const getVendorPayableBillsFactory = () =>
*/
export const getVendorDueBillsFactory = () =>
createSelector( createSelector(
billItemsSelector, billItemsSelector,
billsDueVendorSelector, billsPayableVendorSelector,
(billsItems, dueBillsIds) => { (billsItems, payableBillsIds) => {
return Array.isArray(dueBillsIds) return Array.isArray(payableBillsIds)
? pickItemsFromIds(billsItems, dueBillsIds) || [] ? pickItemsFromIds(billsItems, payableBillsIds) || []
: []; : [];
}, },
);
export const getPayableBillsByPaymentMadeFactory = () =>
createSelector(
billItemsSelector,
billsPayableByPaymentMadeSelector,
(billsItems, payableBillsIds) => {
return Array.isArray(payableBillsIds)
? pickItemsFromIds(billsItems, payableBillsIds) || []
: [];
},
); );

View File

@@ -10,5 +10,7 @@ export default {
BILLS_PAGE_SET: 'BILLS_PAGE_SET', BILLS_PAGE_SET: 'BILLS_PAGE_SET',
BILLS_ITEMS_SET: 'BILLS_ITEMS_SET', BILLS_ITEMS_SET: 'BILLS_ITEMS_SET',
BILL_NUMBER_CHANGED: 'BILL_NUMBER_CHANGED', BILL_NUMBER_CHANGED: 'BILL_NUMBER_CHANGED',
DUE_BILLS_SET: 'DUE_BILLS_SET'
BILLS_PAYABLE_BY_PAYMENT_ID: 'BILLS_PAYABLE_BY_PAYMENT_ID',
BILLS_PAYABLE_BY_VENDOR_ID: 'BILLS_PAYABLE_BY_VENDOR_ID',
}; };

View File

@@ -106,7 +106,20 @@ export const fetchPaymentMade = ({ id }) => {
type: t.PAYMENT_MADE_SET, type: t.PAYMENT_MADE_SET,
payload: { payload: {
id, id,
bill_payment: response.data.bill_payment, paymentMade: response.data.bill_payment,
},
});
dispatch({
type: t.BILLS_PAYABLE_BY_PAYMENT_ID,
payload: {
billPaymentId: id,
bills: response.data.bill_payment.payable_bills,
},
});
dispatch({
type: t.BILLS_ITEMS_SET,
payload: {
bills: response.data.bill_payment.payable_bills,
}, },
}); });
resovle(response); resovle(response);
@@ -118,3 +131,17 @@ export const fetchPaymentMade = ({ id }) => {
}); });
}); });
}; };
export const fetchPaymentMadeBills = ({ paymentMadeId }) => (dispatch) => {
return new Promise((resolve, reject) => {
ApiService.get(`purchases/bill_payments/${paymentMadeId}/bills`).then((response) => {
dispatch({
type: t.BILLS_ITEMS_SET,
payload: {
bills: response.data.bills,
},
});
resolve(response);
}).catch((error) => { reject(error) });
});
}

View File

@@ -39,6 +39,17 @@ const reducer = createReducer(initialState, {
}; };
}, },
[t.PAYMENT_MADE_SET]: (state, action) => {
const { id, paymentMade } = action.payload;
const _oldPaymentMade = (state.items[id] || {});
state.items[id] = {
...defaultPaymentMade,
..._oldPaymentMade,
...paymentMade,
};
},
[t.PAYMENT_MADE_DELETE]: (state, action) => { [t.PAYMENT_MADE_DELETE]: (state, action) => {
const { id } = action.payload; const { id } = action.payload;

View File

@@ -23,6 +23,9 @@ const paymentMadesIds = (state, props) => {
return state.paymentMades.items[props.paymentMadeId]; return state.paymentMades.items[props.paymentMadeId];
}; };
const paymentMadeEntries = (state, props) => props.paymentMadeEntries;
const billsItemsSelector = (state, props) => state.bills.items;
export const getPaymentMadeCurrentPageFactory = () => export const getPaymentMadeCurrentPageFactory = () =>
createSelector( createSelector(
paymentMadesPageSelector, paymentMadesPageSelector,
@@ -54,3 +57,15 @@ export const getPaymentMadeByIdFactory = () =>
createSelector(paymentMadesIds, (payment_Made) => { createSelector(paymentMadesIds, (payment_Made) => {
return payment_Made; return payment_Made;
}); });
export const getPaymentMadeEntriesDataFactory = () =>
createSelector(
billsItemsSelector,
paymentMadeEntries,
(billsItems, paymentEntries) => {
return Array.isArray(paymentEntries) ?
paymentEntries.map((entry) => ({
...entry, ...(billsItems[entry.bill_id] || {}),
})) : [];
}
)

View File

@@ -70,6 +70,7 @@ $pt-font-family: Noto Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
@import 'pages/estimates'; @import 'pages/estimates';
@import 'pages/invoice-form'; @import 'pages/invoice-form';
@import 'pages/receipt-form'; @import 'pages/receipt-form';
@import 'pages/payment-made';
// Views // Views
@import 'views/filter-dropdown'; @import 'views/filter-dropdown';
@@ -219,8 +220,16 @@ body.authentication {
padding: 15px; padding: 15px;
margin: 15px 0 0 0; margin: 15px 0 0 0;
} }
}
&__floating-actions{
position: fixed;
bottom: 0;
width: 100%;
background: #fff;
padding: 14px 18px;
border-top: 1px solid #ececec;
}
}
.datatable-editor{ .datatable-editor{
padding: 15px 15px 0; padding: 15px 15px 0;
@@ -390,4 +399,33 @@ body.authentication {
} }
} }
} }
}
.cloud-spinner{
position: relative;
&.is-loading:before{
content: "";
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
background: rgba(255, 255, 255, 0.8);
z-index: 999;
}
.bp3-spinner{
position: absolute;
z-index: 999999;
left: 50%;
top: 50%;
margin-top: -20px;
margin-left: -20px;
}
&:not(.is-loading) .bp3-spinner{
display: none;
}
} }

View File

@@ -0,0 +1,61 @@
.page-form--payment-made {
$self: '.page-form';
#{$self}__header{
.bp3-label{
min-width: 160px;
}
.bp3-form-content{
width: 100%;
}
.bp3-form-group{
margin-bottom: 18px;
&.bp3-inline{
max-width: 470px;
}
a.receive-full-amount{
font-size: 12px;
margin-top: 6px;
display: inline-block;
}
}
}
#{$self}__primary-section{
padding-bottom: 2px;
}
.datatable-editor{
.table .tbody{
.tr.no-results{
.td{
border-bottom: 1px solid #e2e2e2;
font-size: 15px;
padding: 26px 0;
color: #5a5a77;
}
}
}
.table{
.tr{
.td:first-of-type,
.th:first-of-type{
span, div{
margin-left: auto;
margin-right: auto;
}
}
}
}
}
#{$self}__footer{
}
}

View File

@@ -243,3 +243,7 @@ export const flatToNestedArray = (
}); });
return nestedArray; return nestedArray;
}; };
export const orderingLinesIndexes = (lines, attribute = 'index') => {
return lines.map((line, index) => ({ ...line, [attribute]: index + 1 }));
};

View File

@@ -151,7 +151,8 @@ export default class BillsController extends BaseController {
get dueBillsListingValidationSchema() { get dueBillsListingValidationSchema() {
return [ return [
query('vendor_id').optional().trim().escape(), query('vendor_id').optional().trim().escape(),
] query('payment_made_id').optional().trim().escape(),
];
} }
/** /**
@@ -331,7 +332,13 @@ export default class BillsController extends BaseController {
errors: [{ type: 'BILL_ENTRIES_IDS_NOT_FOUND', code: 900 }], errors: [{ type: 'BILL_ENTRIES_IDS_NOT_FOUND', code: 900 }],
}); });
} }
if (error.errorType === 'ITEMS_NOT_FOUND') {
return res.boom.badRequest(null, {
errors: [{ type: 'ITEMS_NOT_FOUND', code: 1000 }],
});
}
} }
console.log(error.errorType);
next(error); next(error);
} }
} }

View File

@@ -198,8 +198,14 @@ export default class BillsPayments extends BaseController {
const { id: billPaymentId } = req.params; const { id: billPaymentId } = req.params;
try { try {
const billPayment = await this.billPaymentService.getBillPayment(tenantId, billPaymentId); const { billPayment, payableBills } = await this.billPaymentService.getBillPayment(tenantId, billPaymentId);
return res.status(200).send({ bill_payment: billPayment });
return res.status(200).send({
bill_payment: {
...this.transfromToResponse({ ...billPayment }),
payable_bills: payableBills,
},
});
} catch (error) { } catch (error) {
next(error); next(error);
} }

View File

@@ -1,6 +1,5 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { check, param, query } from 'express-validator'; import { check, param, query } from 'express-validator';
import { raw } from 'objection';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import BaseController from '../BaseController'; import BaseController from '../BaseController';
import asyncMiddleware from 'api/middleware/asyncMiddleware'; import asyncMiddleware from 'api/middleware/asyncMiddleware';
@@ -52,11 +51,11 @@ export default class SaleInvoicesController extends BaseController{
this.handleServiceErrors, this.handleServiceErrors,
); );
router.get( router.get(
'/due', [ '/payable', [
...this.dueSalesInvoicesListValidationSchema, ...this.dueSalesInvoicesListValidationSchema,
], ],
this.validationResult, this.validationResult,
asyncMiddleware(this.getDueInvoices.bind(this)), asyncMiddleware(this.getPayableInvoices.bind(this)),
this.handleServiceErrors, this.handleServiceErrors,
); );
router.get( router.get(
@@ -251,12 +250,12 @@ export default class SaleInvoicesController extends BaseController{
* @param {NextFunction} next - * @param {NextFunction} next -
* @return {Response|void} * @return {Response|void}
*/ */
public async getDueInvoices(req: Request, res: Response, next: NextFunction) { public async getPayableInvoices(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req; const { tenantId } = req;
const { customerId } = this.matchedQueryData(req); const { customerId } = this.matchedQueryData(req);
try { try {
const salesInvoices = await this.saleInvoiceService.getDueInvoices(tenantId, customerId); const salesInvoices = await this.saleInvoiceService.getPayableInvoices(tenantId, customerId);
return res.status(200).send({ return res.status(200).send({
sales_invoices: this.transfromToResponse(salesInvoices), sales_invoices: this.transfromToResponse(salesInvoices),

View File

@@ -70,7 +70,7 @@ export default class BillPayment extends TenantModel {
filter(builder) { filter(builder) {
builder.where('reference_type', 'BillPayment'); builder.where('reference_type', 'BillPayment');
}, },
} },
}; };
} }

View File

@@ -489,7 +489,7 @@ export default class BillPaymentsService {
* @return {object} * @return {object}
*/ */
public async getBillPayment(tenantId: number, billPaymentId: number) { public async getBillPayment(tenantId: number, billPaymentId: number) {
const { BillPayment } = this.tenancy.models(tenantId); const { BillPayment, Bill } = this.tenancy.models(tenantId);
const billPayment = await BillPayment.query() const billPayment = await BillPayment.query()
.findById(billPaymentId) .findById(billPaymentId)
.withGraphFetched('entries') .withGraphFetched('entries')
@@ -499,7 +499,16 @@ export default class BillPaymentsService {
if (!billPayment) { if (!billPayment) {
throw new ServiceError(ERRORS.PAYMENT_MADE_NOT_FOUND); throw new ServiceError(ERRORS.PAYMENT_MADE_NOT_FOUND);
} }
return billPayment;
const payableBills = await Bill.query().onBuild((builder) => {
const billsIds = billPayment.entries.map((entry) => entry.billId);
builder.where('vendor_id', billPayment.vendorId);
builder.orWhereIn('id', billsIds);
builder.orderByRaw(`FIELD(id, ${billsIds.join(', ')}) DESC`);
builder.orderBy('bill_date', 'ASC');
})
return { billPayment, payableBills };
} }
/** /**

View File

@@ -1,4 +1,4 @@
import { omit, sumBy, pick, difference } from 'lodash'; import { omit, sumBy, pick, difference, assignWith } from 'lodash';
import moment from 'moment'; import moment from 'moment';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { import {
@@ -23,11 +23,13 @@ import {
IPaginationMeta, IPaginationMeta,
IFilterMeta, IFilterMeta,
IBillsFilter, IBillsFilter,
IBillPaymentEntry,
} from 'interfaces'; } from 'interfaces';
import { ServiceError } from 'exceptions'; import { ServiceError } from 'exceptions';
import ItemsService from 'services/Items/ItemsService'; import ItemsService from 'services/Items/ItemsService';
import ItemsEntriesService from 'services/Items/ItemsEntriesService'; import ItemsEntriesService from 'services/Items/ItemsEntriesService';
import { Bill } from 'models'; import { Bill } from 'models';
import PaymentMadesSubscriber from 'subscribers/paymentMades';
const ERRORS = { const ERRORS = {
BILL_NOT_FOUND: 'BILL_NOT_FOUND', BILL_NOT_FOUND: 'BILL_NOT_FOUND',
@@ -428,6 +430,7 @@ export default class BillsService extends SalesInvoicesCost {
const { Bill } = this.tenancy.models(tenantId); const { Bill } = this.tenancy.models(tenantId);
const dueBills = await Bill.query().onBuild((query) => { const dueBills = await Bill.query().onBuild((query) => {
query.orderBy('bill_date', 'DESC');
query.modify('dueBills'); query.modify('dueBills');
if (vendorId) { if (vendorId) {

View File

@@ -413,7 +413,7 @@ export default class SaleInvoicesService extends SalesInvoicesCost {
* @param {number} tenantId * @param {number} tenantId
* @param {number} customerId * @param {number} customerId
*/ */
public async getDueInvoices( public async getPayableInvoices(
tenantId: number, tenantId: number,
customerId?: number, customerId?: number,
): Promise<ISaleInvoice> { ): Promise<ISaleInvoice> {