feat: Edit make journal entries.

This commit is contained in:
Ahmed Bouhuolia
2020-04-08 17:50:35 +02:00
parent 8ba96e7343
commit 4920d5f419
16 changed files with 225 additions and 75 deletions

View File

@@ -1,29 +1,23 @@
import React, {useMemo, useCallback, useState} from 'react'; import React, {useCallback, useState} from 'react';
import {omit} from 'lodash';
import { import {
MenuItem, MenuItem,
FormGroup,
Button, Button,
Intent,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import {Select} from '@blueprintjs/select'; import {Select} from '@blueprintjs/select';
// import MultiSelect from 'components/MultiSelect';
export default function AccountsMultiSelect({ export default function AccountsMultiSelect({
accounts, accounts,
onAccountSelected, onAccountSelected,
error, error,
initialAccount,
}) { }) {
const [selectedAccount, setSelectedAccount] = useState(null); const [selectedAccount, setSelectedAccount] = useState(
initialAccount || null
);
// Account item of select accounts field. // Account item of select accounts field.
const accountItem = useCallback((item, { handleClick, modifiers, query }) => { const accountItem = useCallback((item, { handleClick, modifiers, query }) => {
return ( return (
<MenuItem <MenuItem text={item.name} label={item.code} key={item.id} onClick={handleClick} />
text={item.name}
label={item.code}
key={item.id}
onClick={handleClick} />
); );
}, []); }, []);
@@ -33,20 +27,17 @@ export default function AccountsMultiSelect({
}, [setSelectedAccount, onAccountSelected]); }, [setSelectedAccount, onAccountSelected]);
return ( return (
<Select <Select
items={accounts} items={accounts}
noResults={<MenuItem disabled={true} text='No results.' />} noResults={<MenuItem disabled={true} text='No results.' />}
itemRenderer={accountItem} itemRenderer={accountItem}
popoverProps={{ minimal: true }} popoverProps={{ minimal: true }}
filterable={true} filterable={true}
onItemSelect={onAccountSelect} onItemSelect={onAccountSelect}>
>
<Button <Button
rightIcon='caret-down' rightIcon='caret-down'
text={selectedAccount ? selectedAccount.name : 'Select account'} text={selectedAccount ? selectedAccount.name : 'Select account'}
/> />
</Select> </Select>
); );
} }

View File

@@ -1,4 +1,4 @@
import React, {useCallback} from 'react'; import React, {useCallback, useMemo} from 'react';
import AccountsSelectList from 'components/AccountsSelectList'; import AccountsSelectList from 'components/AccountsSelectList';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
@@ -20,6 +20,10 @@ const AccountCellRenderer = ({
const { account_id = false } = (errors[index] || {}); const { account_id = false } = (errors[index] || {});
const initialAccount = useMemo(() =>
accounts.find(a => a.id === initialValue),
[accounts, initialValue]);
return ( return (
<FormGroup <FormGroup
intent={account_id ? Intent.DANGER : ''} intent={account_id ? Intent.DANGER : ''}
@@ -31,7 +35,8 @@ const AccountCellRenderer = ({
<AccountsSelectList <AccountsSelectList
accounts={accounts} accounts={accounts}
onAccountSelected={handleAccountSelected} onAccountSelected={handleAccountSelected}
error={account_id} /> error={account_id}
initialAccount={initialAccount} />
</FormGroup> </FormGroup>
); );
}; };

View File

@@ -64,7 +64,9 @@ export default function MoneyFieldGroup({
const options = useMemo(() => ({ const options = useMemo(() => ({
prefix, suffix, thousands, decimal, precision, prefix, suffix, thousands, decimal, precision,
}), []); }), [
prefix, suffix, thousands, decimal, precision,
]);
const handleChange = useCallback((event) => { const handleChange = useCallback((event) => {
const formatted = formatter(event.target.value, options); const formatted = formatter(event.target.value, options);
@@ -72,12 +74,12 @@ export default function MoneyFieldGroup({
setState(formatted); setState(formatted);
onChange && onChange(event, value); onChange && onChange(event, value);
}, []); }, [onChange, options]);
useEffect(() => { useEffect(() => {
const formatted = formatter(value, options); const formatted = formatter(value, options);
setState(formatted) setState(formatted)
}, []); }, [value, options, setState]);
return ( return (
<InputGroup <InputGroup

View File

@@ -1,17 +1,21 @@
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import { import {
makeJournalEntries, makeJournalEntries,
fetchManualJournal,
editManualJournal,
} from 'store/accounting/accounting.actions'; } from 'store/accounting/accounting.actions';
import t from 'store/types'; import {
getManualJournal,
} from 'store/accounting/accounting.reducers';
export const mapStateToProps = (state, props) => ({ export const mapStateToProps = (state, props) => ({
getManualJournal: (id) => getManualJournal(state, id),
}); });
export const mapDispatchToProps = (dispatch) => ({ export const mapDispatchToProps = (dispatch) => ({
makeJournalEntries: (form) => dispatch(makeJournalEntries({ form })), requestMakeJournalEntries: (form) => dispatch(makeJournalEntries({ form })),
fetchManualJournal: (id) => dispatch(fetchManualJournal({ id })),
requestEditManualJournal: (id, form) => dispatch(editManualJournal({ id, form }))
}); });
export default connect(mapStateToProps, mapDispatchToProps); export default connect(mapStateToProps, mapDispatchToProps);

View File

@@ -13,21 +13,23 @@ import {compose} from 'utils';
import useAsync from 'hooks/async'; import useAsync from 'hooks/async';
import moment from 'moment'; import moment from 'moment';
import AppToaster from 'components/AppToaster'; import AppToaster from 'components/AppToaster';
import {pick, omit} from 'lodash';
function MakeJournalEntriesForm({ function MakeJournalEntriesForm({
makeJournalEntries, requestMakeJournalEntries,
fetchAccounts, requestEditManualJournal,
changePageTitle, changePageTitle,
changePageSubtitle,
editJournal,
}) { }) {
useEffect(() => { useEffect(() => {
changePageTitle('New Journal'); if (editJournal && editJournal.id) {
}, []); changePageTitle('Edit Journal');
changePageSubtitle(`No. ${editJournal.journal_number}`);
const fetchHook = useAsync(async () => { } else {
await Promise.all([ changePageTitle('New Journal');
fetchAccounts(), }
]); }, [changePageTitle, changePageSubtitle, editJournal]);
});
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
journal_number: Yup.string().required(), journal_number: Yup.string().required(),
@@ -54,22 +56,31 @@ function MakeJournalEntriesForm({
note: '', note: '',
}), []); }), []);
const initialValues = useMemo(() => ({
journal_number: '',
date: moment(new Date()).format('YYYY-MM-DD'),
description: '',
reference: '',
entries: [
defaultEntry,
defaultEntry,
defaultEntry,
defaultEntry,
],
}), [defaultEntry]);
const formik = useFormik({ const formik = useFormik({
enableReinitialize: true, enableReinitialize: true,
validationSchema, validationSchema,
initialValues: { initialValues: {
journal_number: '', ...(editJournal) ? {
date: moment(new Date()).format('YYYY-MM-DD'), ...pick(editJournal, Object.keys(initialValues)),
description: '', entries: editJournal.entries.map((entry) => ({
reference: '', ...pick(entry, Object.keys(defaultEntry)),
entries: [ }))
defaultEntry, } : {
defaultEntry, ...initialValues,
defaultEntry, }
defaultEntry,
defaultEntry,
defaultEntry,
],
}, },
onSubmit: (values, actions) => { onSubmit: (values, actions) => {
const form = values.entries.filter((entry) => ( const form = values.entries.filter((entry) => (
@@ -87,18 +98,31 @@ function MakeJournalEntriesForm({
AppToaster.show({ AppToaster.show({
message: 'credit_and_debit_not_equal', message: 'credit_and_debit_not_equal',
}); });
actions.setSubmitting(false);
return; return;
} }
makeJournalEntries({ ...values, entries: form }) if (editJournal && editJournal.id) {
.then((response) => { requestEditManualJournal(editJournal.id, { ...values, entries: form })
AppToaster.show({ .then((response) => {
message: 'manual_journal_has_been_submit', AppToaster.show({
}); message: 'manual_journal_has_been_edited',
actions.setSubmitting(false); });
}).catch((error) => { actions.setSubmitting(false);
actions.setSubmitting(false); }).catch((error) => {
}); actions.setSubmitting(false);
});
} else {
requestMakeJournalEntries({ ...values, entries: form })
.then((response) => {
AppToaster.show({
message: 'manual_journal_has_been_submit',
});
actions.setSubmitting(false);
}).catch((error) => {
actions.setSubmitting(false);
});
}
}, },
}); });

View File

@@ -21,12 +21,13 @@ export default function MakeJournalEntriesHeader({
}) { }) {
const intl = useIntl(); const intl = useIntl();
const handleDateChange = (date) => { const handleDateChange = useCallback((date) => {
const formatted = moment(date).format('YYYY-MM-DD'); const formatted = moment(date).format('YYYY-MM-DD');
formik.setFieldValue('date', formatted); formik.setFieldValue('date', formatted);
}; }, [formik]);
const infoIcon = useMemo(() => (<Icon icon="info-circle" iconSize={12} />), []); const infoIcon = useMemo(() =>
(<Icon icon="info-circle" iconSize={12} />), []);
return ( return (
<div class="make-journal-entries__header"> <div class="make-journal-entries__header">

View File

@@ -1,14 +1,39 @@
import React from 'react'; import React, {useMemo} from 'react';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import MakeJournalEntriesForm from './MakeJournalEntriesForm'; import MakeJournalEntriesForm from './MakeJournalEntriesForm';
import LoadingIndicator from 'components/LoadingIndicator';
import DashboardConnect from 'connectors/Dashboard.connector'; import DashboardConnect from 'connectors/Dashboard.connector';
import {compose} from 'utils'; import {compose} from 'utils';
import MakeJournalEntriesConnect from 'connectors/MakeJournalEntries.connect';
import AccountsConnect from 'connectors/Accounts.connector';
function MakeJournalEntriesPage({
fetchManualJournal,
getManualJournal,
fetchAccounts,
}) {
const { id } = useParams();
const fetchJournal = useAsync(() => {
return Promise.all([
fetchAccounts(),
(id) && fetchManualJournal(id),
]);
});
const editJournal = useMemo(() =>
getManualJournal(id) || null,
[getManualJournal, id]);
function MakeJournalEntriesPage() {
return ( return (
<MakeJournalEntriesForm /> <LoadingIndicator loading={fetchJournal.loading} mount={false}>
<MakeJournalEntriesForm editJournal={editJournal} />
</LoadingIndicator>
); );
} }
export default compose( export default compose(
DashboardConnect, DashboardConnect,
AccountsConnect,
MakeJournalEntriesConnect,
)(MakeJournalEntriesPage); )(MakeJournalEntriesPage);

View File

@@ -70,6 +70,9 @@ const NoteCellRenderer = (chainedComponent) => (props) => {
return chainedComponent(props); return chainedComponent(props);
}; };
/**
* Make journal entries table component.
*/
function MakeJournalEntriesTable({ function MakeJournalEntriesTable({
formik, formik,
accounts, accounts,
@@ -79,6 +82,8 @@ function MakeJournalEntriesTable({
}) { }) {
const [rows, setRow] = useState([ const [rows, setRow] = useState([
...formik.values.entries.map((e) => ({ ...e, rowType: 'editor'})), ...formik.values.entries.map((e) => ({ ...e, rowType: 'editor'})),
defaultRow,
defaultRow,
]); ]);
// Handles update datatable data. // Handles update datatable data.
@@ -101,11 +106,15 @@ function MakeJournalEntriesTable({
// Handles click remove datatable row. // Handles click remove datatable row.
const handleRemoveRow = useCallback((rowIndex) => { const handleRemoveRow = useCallback((rowIndex) => {
const removeIndex = parseInt(rowIndex, 10); const removeIndex = parseInt(rowIndex, 10);
setRow([ const newRows = rows.filter((row, index) => index !== removeIndex);
...rows.filter((row, index) => index !== removeIndex),
]); setRow([ ...newRows ]);
formik.setFieldValue('entries', newRows
.filter(row => row.rowType === 'editor')
.map(row => ({ ...omit(row, ['rowType']) })
));
onClickRemoveRow && onClickRemoveRow(removeIndex); onClickRemoveRow && onClickRemoveRow(removeIndex);
}, [rows, onClickRemoveRow]); }, [rows, formik, onClickRemoveRow]);
// Memorized data table columns. // Memorized data table columns.
const columns = useMemo(() => [ const columns = useMemo(() => [
@@ -123,7 +132,7 @@ function MakeJournalEntriesTable({
{ {
Header: 'Account', Header: 'Account',
id: 'account_id', id: 'account_id',
accessor: 'account', accessor: 'account_id',
Cell: TotalAccountCellRenderer(AccountsListFieldCell), Cell: TotalAccountCellRenderer(AccountsListFieldCell),
className: "account", className: "account",
disableSortBy: true, disableSortBy: true,

View File

@@ -66,6 +66,15 @@ export default [
text: 'Make Journal Entry' text: 'Make Journal Entry'
}, },
{
path: `${BASE_URL}/accounting/manual-journal/:id`,
name: 'dashboard.manual.journal.edit',
component: LazyLoader({
loader: () =>
import('containers/Dashboard/Accounting/MakeJournalEntriesPage')
}),
},
// Items // Items
{ {
path: `${BASE_URL}/items/list`, path: `${BASE_URL}/items/list`,

View File

@@ -7,4 +7,27 @@ export const makeJournalEntries = ({ form }) => {
resolve(response); resolve(response);
}).catch((error) => { reject(error); }); }).catch((error) => { reject(error); });
}); });
};
export const fetchManualJournal = ({ id }) => {
return (dispatch) => new Promise((resolve, reject) => {
ApiService.get(`accounting/manual-journals/${id}`).then((response) => {
dispatch({
type: t.MANUAL_JOURNAL_SET,
payload: {
id,
manualJournal: response.data.manual_journal,
}
});
resolve(response);
}).catch((error) => { reject(error); });
});
};
export const editManualJournal = ({ form, id }) => {
return (dispatch) => new Promise((resolve, reject) => {
ApiService.post(`accounting/manual-journals/${id}`, form).then((response) => {
resolve(response);
}).catch((error) => { reject(error); });
});
} }

View File

@@ -0,0 +1,19 @@
import t from 'store/types';
import { createReducer } from '@reduxjs/toolkit';
const initialState = {
manualJournals: {},
};
export default createReducer(initialState, {
[t.MANUAL_JOURNAL_SET]: (state, action) => {
const { id, manualJournal } = action.payload;
state.manualJournals[id] = manualJournal;
},
});
export const getManualJournal = (state, id) => {
return state.accounting.manualJournals[id];
}

View File

@@ -2,4 +2,5 @@
export default { export default {
MAKE_JOURNAL_ENTRIES: 'MAKE_JOURNAL_ENTRIES', MAKE_JOURNAL_ENTRIES: 'MAKE_JOURNAL_ENTRIES',
MANUAL_JOURNAL_SET: 'MANUAL_JOURNAL_SET',
} }

View File

@@ -13,12 +13,14 @@ import resources from './resources/resources.reducer';
import financialStatements from './financialStatement/financialStatements.reducer'; import financialStatements from './financialStatement/financialStatements.reducer';
import itemCategories from './itemCategories/itemsCateory.reducer'; import itemCategories from './itemCategories/itemsCateory.reducer';
import settings from './settings/settings.reducer'; import settings from './settings/settings.reducer';
import accounting from './accounting/accounting.reducers';
export default combineReducers({ export default combineReducers({
authentication, authentication,
dashboard, dashboard,
users, users,
accounts, accounts,
accounting,
fields, fields,
views, views,
expenses, expenses,

View File

@@ -1,5 +1,6 @@
import authentication from './authentication/authentication.types'; import authentication from './authentication/authentication.types';
import accounts from './accounts/accounts.types'; import accounts from './accounts/accounts.types';
import accounting from './accounting/accounting.types'
import currencies from './currencies/currencies.types'; import currencies from './currencies/currencies.types';
import customFields from './customFields/customFields.types'; import customFields from './customFields/customFields.types';
import customViews from './customViews/customViews.types'; import customViews from './customViews/customViews.types';
@@ -27,5 +28,6 @@ export default {
...users, ...users,
...financialStatements, ...financialStatements,
...itemCategories, ...itemCategories,
...settings ...settings,
...accounting,
}; };

View File

@@ -25,6 +25,10 @@ export default {
const router = express.Router(); const router = express.Router();
router.use(JWTAuth); router.use(JWTAuth);
router.get('/manual-journals/:id',
this.getManualJournal.validation,
asyncMiddleware(this.getManualJournal.handler));
router.get('/manual-journals', router.get('/manual-journals',
this.manualJournals.validation, this.manualJournals.validation,
asyncMiddleware(this.manualJournals.handler)); asyncMiddleware(this.manualJournals.handler));
@@ -33,7 +37,7 @@ export default {
this.makeJournalEntries.validation, this.makeJournalEntries.validation,
asyncMiddleware(this.makeJournalEntries.handler)); asyncMiddleware(this.makeJournalEntries.handler));
router.post('/manual-journal/:id', router.post('/manual-journals/:id',
this.editManualJournal.validation, this.editManualJournal.validation,
asyncMiddleware(this.editManualJournal.handler)); asyncMiddleware(this.editManualJournal.handler));
@@ -375,6 +379,26 @@ export default {
journal.loadEntries(transactions); journal.loadEntries(transactions);
journal.removeEntries(); journal.removeEntries();
entries.forEach((entry) => {
const account = accounts.find((a) => a.id === entry.account_id);
const jouranlEntry = new JournalEntry({
debit: entry.debit,
credit: entry.credit,
account: account.id,
referenceType: 'Journal',
referenceId: manualJournal.id,
accountNormal: account.type.normal,
note: entry.note,
date: formattedDate,
userId: user.id,
});
if (entry.debit) {
journal.debit(jouranlEntry);
} else {
journal.credit(jouranlEntry);
}
});
await Promise.all([ await Promise.all([
journal.deleteEntries(), journal.deleteEntries(),
journal.saveEntries(), journal.saveEntries(),
@@ -405,8 +429,19 @@ export default {
return res.status(404).send({ return res.status(404).send({
errors: [{ type: 'MANUAL.JOURNAL.NOT.FOUND', code: 100 }], errors: [{ type: 'MANUAL.JOURNAL.NOT.FOUND', code: 100 }],
}); });
} }
const transactions = await AccountTransaction.query()
.whereIn('reference_type', ['Journal', 'ManualJournal'])
.where('reference_id', manualJournal.id);
return res.status(200).send({
manual_journal: {
...manualJournal.toJSON(),
entries: [
...transactions,
],
},
});
}, },
}, },

View File

@@ -16,7 +16,6 @@ describe('routes: /accounts/', () => {
.post('/api/accounts') .post('/api/accounts')
.set('x-access-token', loginRes.body.token) .set('x-access-token', loginRes.body.token)
.send(); .send();
expect(res.status).equals(422); expect(res.status).equals(422);
expect(res.body.code).equals('validation_error'); expect(res.body.code).equals('validation_error');
}); });
@@ -191,7 +190,6 @@ describe('routes: /accounts/', () => {
}); });
describe('GET: `/accounts`', () => { describe('GET: `/accounts`', () => {
it('Should retrieve accounts resource not found.', async () => { it('Should retrieve accounts resource not found.', async () => {
const res = await request() const res = await request()
.get('/api/accounts') .get('/api/accounts')