diff --git a/client/src/components/AccountsSelectList.js b/client/src/components/AccountsSelectList.js index 02a01adff..b326184f2 100644 --- a/client/src/components/AccountsSelectList.js +++ b/client/src/components/AccountsSelectList.js @@ -2,7 +2,9 @@ import React, {useMemo, useCallback, useState} from 'react'; import {omit} from 'lodash'; import { MenuItem, - Button + FormGroup, + Button, + Intent, } from '@blueprintjs/core'; import {Select} from '@blueprintjs/select'; // import MultiSelect from 'components/MultiSelect'; @@ -10,6 +12,7 @@ import {Select} from '@blueprintjs/select'; export default function AccountsMultiSelect({ accounts, onAccountSelected, + error, }) { const [selectedAccount, setSelectedAccount] = useState(null); @@ -30,18 +33,20 @@ export default function AccountsMultiSelect({ }, [setSelectedAccount, onAccountSelected]); return ( + + ); } \ No newline at end of file diff --git a/client/src/components/DataTableCells/AccountsListFieldCell.js b/client/src/components/DataTableCells/AccountsListFieldCell.js index c2a173de6..10118bbfa 100644 --- a/client/src/components/DataTableCells/AccountsListFieldCell.js +++ b/client/src/components/DataTableCells/AccountsListFieldCell.js @@ -1,24 +1,36 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import AccountsSelectList from 'components/AccountsSelectList'; import classNames from 'classnames'; import { FormGroup, Classes, + Intent, } from '@blueprintjs/core'; // Account cell renderer. const AccountCellRenderer = ({ + column: { id, value }, row: { index, original }, - payload: { accounts } + payload: { accounts, updateData, errors }, }) => { + const handleAccountSelected = useCallback((account) => { + updateData(index, id, account.id); + }, [updateData, index, id]); + + const { account_id = false } = (errors[index] || {}); + return ( {}} /> + onAccountSelected={handleAccountSelected} + error={account_id} /> ); }; diff --git a/client/src/components/DataTableCells/MoneyFieldCell.js b/client/src/components/DataTableCells/MoneyFieldCell.js index db1e9b2c5..cfe7a182b 100644 --- a/client/src/components/DataTableCells/MoneyFieldCell.js +++ b/client/src/components/DataTableCells/MoneyFieldCell.js @@ -15,7 +15,7 @@ const MoneyFieldCellRenderer = ({ }, []); const onBlur = () => { - payload.updateData(index, id, value) + payload.updateData(index, id, parseFloat(value)); }; return ( credit || debit, + then: Yup.number().required(), + }), + note: Yup.string().nullable(), }), ) }); - const defaultEntry = { + const defaultEntry = useMemo(() => ({ account_id: null, credit: null, debit: null, - note: '', - }; + note: '', + }), []); const formik = useFormik({ enableReinitialize: true, validationSchema, initialValues: { - reference: '', + journal_number: '', date: moment(new Date()).format('YYYY-MM-DD'), description: '', + reference: '', entries: [ defaultEntry, defaultEntry, defaultEntry, defaultEntry, + defaultEntry, + defaultEntry, ], }, onSubmit: (values) => { const form = values.entries.filter((entry) => ( (entry.credit || entry.debit) )); - makeJournalEntries(values).then((response) => { - AppToaster.show({ - message: 'the_account_has_been_submit', - }); - }).catch((error) => { + const getTotal = (type = 'credit') => { + return form.reduce((total, item) => { + return item[type] ? item[type] + total : total; + }, 0); + } + const totalCredit = getTotal('credit'); + const totalDebit = getTotal('debit'); - }); + if (totalCredit !== totalDebit) { + AppToaster.show({ + message: 'credit_and_debit_not_equal', + }); + return; + } + + makeJournalEntries({ ...values, entries: form }) + .then((response) => { + AppToaster.show({ + message: 'manual_journal_has_been_submit', + }); + }).catch((error) => { + + }); }, }); diff --git a/client/src/containers/Dashboard/Accounting/MakeJournalEntriesTable.js b/client/src/containers/Dashboard/Accounting/MakeJournalEntriesTable.js index 5dde6a63f..e64a3aaf8 100644 --- a/client/src/containers/Dashboard/Accounting/MakeJournalEntriesTable.js +++ b/client/src/containers/Dashboard/Accounting/MakeJournalEntriesTable.js @@ -1,9 +1,9 @@ import React, {useState, useMemo, useCallback} from 'react'; -import DataTable from 'components/DataTable'; import { Button, Intent, } from '@blueprintjs/core'; +import DataTable from 'components/DataTable'; import Icon from 'components/Icon'; import AccountsConnect from 'connectors/Accounts.connector.js'; import {compose, formattedAmount} from 'utils'; @@ -11,7 +11,8 @@ import { AccountsListFieldCell, MoneyFieldCell, InputGroupCell, -} from 'comp}onents/DataTableCells'; +} from 'components/DataTableCells'; +import { omit } from 'lodash'; // Actions cell renderer. const ActionsCellRenderer = ({ @@ -50,7 +51,7 @@ const TotalAccountCellRenderer = (chainedComponent) => (props) => { // Total credit/debit cell renderer. const TotalCreditDebitCellRenderer = (chainedComponent, type) => (props) => { if (props.data.length === (props.row.index + 2)) { - const total = props.data.reduce((total, entry) => { + const total = props.data.reduce((total, entry) => { const amount = parseInt(entry[type], 10); const computed = amount ? total + amount : total; @@ -82,18 +83,20 @@ function MakeJournalEntriesTable({ // Handles update datatable data. const handleUpdateData = useCallback((rowIndex, columnId, value) => { - setRow((old) => - old.map((row, index) => { - if (index === rowIndex) { - return { - ...old[rowIndex], - [columnId]: value, - } - } - return row - }) - ) - }, []); + const newRows = rows.map((row, index) => { + if (index === rowIndex) { + return { + ...rows[rowIndex], + [columnId]: value, + }; + } + return { ...row }; + }); + setRow(newRows); + formik.setFieldValue('entries', newRows.map(row => ({ + ...omit(row, ['rowType']), + }))); + }, [rows, formik]); // Handles click remove datatable row. const handleRemoveRow = useCallback((rowIndex) => { @@ -119,6 +122,7 @@ function MakeJournalEntriesTable({ }, { Header: 'Account', + id: 'account_id', accessor: 'account', Cell: TotalAccountCellRenderer(AccountsListFieldCell), className: "account", @@ -174,6 +178,7 @@ function MakeJournalEntriesTable({ const rowClassNames = useCallback((row) => ({ 'row--total': rows.length === (row.index + 2), }), [rows]); + return (
diff --git a/client/src/style/objects/form.scss b/client/src/style/objects/form.scss index 763488918..a5d4bfa99 100644 --- a/client/src/style/objects/form.scss +++ b/client/src/style/objects/form.scss @@ -98,6 +98,12 @@ label{ &.bp3-fill{ width: 100%; } + + &.bp3-intent-danger{ + .bp3-button:not(.bp3-minimal){ + border-color: #db3737; + } + } } diff --git a/client/src/style/pages/make-journal-entries.scss b/client/src/style/pages/make-journal-entries.scss index 241044e82..a135bc1a3 100644 --- a/client/src/style/pages/make-journal-entries.scss +++ b/client/src/style/pages/make-journal-entries.scss @@ -1,5 +1,6 @@ .make-journal-entries{ + padding-bottom: 80px; &__header{ padding: 25px 27px 20px; @@ -74,6 +75,14 @@ padding-right: 8px; } + .form-group--select-list{ + &.bp3-intent-danger{ + .bp3-button:not(.bp3-minimal){ + border-color: #efa8a8; + } + } + } + &:last-of-type{ .td{ diff --git a/client/src/utils.js b/client/src/utils.js index 77ade104c..2aac703cb 100644 --- a/client/src/utils.js +++ b/client/src/utils.js @@ -1,5 +1,8 @@ import moment from 'moment'; import _ from 'lodash'; +import Currency from 'js-money/lib/currency'; +import accounting from 'accounting'; + export function removeEmptyFromObject(obj) { obj = Object.assign({}, obj); @@ -131,4 +134,12 @@ export const defaultExpanderReducer = (tableRows, level) => { }; walker(tableRows); return expended; +} + + +export function formattedAmount(cents, currency) { + const { symbol, decimal_digits: precision } = Currency[currency]; + const amount = cents / Math.pow(10, precision); + + return accounting.formatMoney(amount, { symbol, precision }); } \ No newline at end of file diff --git a/server/src/database/migrations/20200105195823_create_manual_journals_table.js b/server/src/database/migrations/20200105195823_create_manual_journals_table.js index 176fd50b3..93cc9cbdf 100644 --- a/server/src/database/migrations/20200105195823_create_manual_journals_table.js +++ b/server/src/database/migrations/20200105195823_create_manual_journals_table.js @@ -3,11 +3,12 @@ exports.up = function(knex) { return knex.schema.createTable('manual_journals', (table) => { table.increments(); table.string('journal_number'); + table.string('reference'); table.string('transaction_type'); table.decimal('amount'); table.date('date'); table.boolean('status').defaultTo(false); - table.string('note'); + table.string('description'); table.integer('user_id').unsigned(); table.timestamps(); }); diff --git a/server/src/http/controllers/Accounting.js b/server/src/http/controllers/Accounting.js index c808a6f40..488ec48e9 100644 --- a/server/src/http/controllers/Accounting.js +++ b/server/src/http/controllers/Accounting.js @@ -126,9 +126,11 @@ export default { makeJournalEntries: { validation: [ check('date').isISO8601(), - check('reference').exists(), - check('memo').optional().trim().escape(), - check('entries').isArray({ min: 1 }), + check('journal_number').exists().trim().escape(), + check('transaction_type').optional({ nullable: true }).trim().escape(), + check('reference').optional({ nullable: true }), + check('description').optional().trim().escape(), + check('entries').isArray({ min: 2 }), check('entries.*.credit').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.account_id').isNumeric().toInt(), @@ -144,6 +146,8 @@ export default { } const form = { date: new Date(), + transaction_type: 'journal', + reference: '', ...req.body, }; @@ -183,10 +187,11 @@ export default { errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 }); } - const journalReference = await ManualJournal.query().where('reference', form.reference); + const journalNumber = await ManualJournal.query() + .where('journal_number', form.journal_number); - if (journalReference.length > 0) { - errorReasons.push({ type: 'REFERENCE.ALREADY.EXISTS', code: 300 }); + if (journalNumber.length > 0) { + errorReasons.push({ type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300 }); } if (errorReasons.length > 0) { return res.status(400).send({ errors: errorReasons }); @@ -196,9 +201,10 @@ export default { const manualJournal = await ManualJournal.query().insert({ reference: form.reference, transaction_type: 'Journal', + journal_number: form.journal_number, amount: totalCredit, date: formattedDate, - note: form.memo, + description: form.description, user_id: user.id, }); const journalPoster = new JournalPoster(); @@ -210,7 +216,8 @@ export default { debit: entry.debit, credit: entry.credit, account: account.id, - transactionType: 'Journal', + referenceType: 'Journal', + referenceId: manualJournal.id, accountNormal: account.type.normal, note: entry.note, date: formattedDate, diff --git a/server/tests/routes/accounting.test.js b/server/tests/routes/accounting.test.js index 4068afda3..467908d0b 100644 --- a/server/tests/routes/accounting.test.js +++ b/server/tests/routes/accounting.test.js @@ -18,7 +18,7 @@ describe('routes: `/accounting`', () => { loginRes = null; }); - describe('route: `/accounting/make-journal-entries`', async () => { + describe.only('route: `/accounting/make-journal-entries`', async () => { it('Should sumation of credit or debit does not equal zero.', async () => { const account = await create('account'); const res = await request() @@ -26,6 +26,7 @@ describe('routes: `/accounting`', () => { .set('x-access-token', loginRes.body.token) .send({ date: new Date().toISOString(), + journal_number: '123', reference: 'ASC', entries: [ { @@ -54,7 +55,7 @@ describe('routes: `/accounting`', () => { .set('x-access-token', loginRes.body.token) .send({ date: new Date().toISOString(), - reference: 'ASC', + journal_number: '123', entries: [ { credit: 1000, @@ -85,7 +86,7 @@ describe('routes: `/accounting`', () => { .set('x-access-token', loginRes.body.token) .send({ date: new Date().toISOString(), - reference: manualJournal.reference, + journal_number: manualJournal.journalNumber, entries: [ { credit: 1000, @@ -102,18 +103,18 @@ describe('routes: `/accounting`', () => { expect(res.status).equals(400); expect(res.body.errors).include.something.that.deep.equal({ - type: 'REFERENCE.ALREADY.EXISTS', + type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300, }); }); - it('Should response error in case account id not exists.', async () => { + it('Should response error in case account id not exists in one of the given entries.', async () => { const res = await request() .post('/api/accounting/make-journal-entries') .set('x-access-token', loginRes.body.token) .send({ date: new Date().toISOString(), - reference: '1000', + journal_number: '123', entries: [ { credit: 1000, @@ -144,7 +145,7 @@ describe('routes: `/accounting`', () => { .set('x-access-token', loginRes.body.token) .send({ date: new Date().toISOString(), - reference: '1000', + journal_number: '1000', entries: [ { credit: null, @@ -166,7 +167,7 @@ describe('routes: `/accounting`', () => { }); }); - it('Should store manual journal transaction to the storage.', async () => { + it.only('Should store manual journal transaction to the storage.', async () => { const account1 = await create('account'); const account2 = await create('account'); @@ -175,8 +176,9 @@ describe('routes: `/accounting`', () => { .set('x-access-token', loginRes.body.token) .send({ date: new Date('2020-2-2').toISOString(), - reference: '1000', - memo: 'Description here.', + journal_number: '1000', + reference: '2000', + description: 'Description here.', entries: [ { credit: 1000, @@ -192,12 +194,14 @@ describe('routes: `/accounting`', () => { const foundManualJournal = await ManualJournal.query(); expect(foundManualJournal.length).equals(1); - expect(foundManualJournal[0].reference).equals('1000'); + + expect(foundManualJournal[0].reference).equals('2000'); + expect(foundManualJournal[0].journalNumber).equals('1000'); expect(foundManualJournal[0].transactionType).equals('Journal'); expect(foundManualJournal[0].amount).equals(1000); expect(moment(foundManualJournal[0].date).format('YYYY-MM-DD')).equals('2020-02-02'); - expect(foundManualJournal[0].note).equals('Description here.'); - expect(foundManualJournal[0].userId).equals(1); + expect(foundManualJournal[0].description).equals('Description here.'); + expect(foundManualJournal[0].userId).to.be.a('integer'); }); it('Should store journal transactions to the storage.', async () => { @@ -246,7 +250,7 @@ describe('routes: `/accounting`', () => { }); - describe.only('route: `accounting/manual-journals`', async () => { + describe('route: `accounting/manual-journals`', async () => { it('Should retrieve manual journals resource not found.', async () => { const res = await request()