,
+ href: '/expenses-list',
},
],
},
diff --git a/client/src/containers/Accounts/AccountsChart.js b/client/src/containers/Accounts/AccountsChart.js
index 6d118a161..f94dd94d8 100644
--- a/client/src/containers/Accounts/AccountsChart.js
+++ b/client/src/containers/Accounts/AccountsChart.js
@@ -327,7 +327,7 @@ const handleConfirmBulkActivate = useCallback(() => {
onEditAccount={handleEditAccount}
onFetchData={handleFetchData}
onSelectedRowsChange={handleSelectedRowsChange}
- loading={tableLoading}
+
/>
diff --git a/client/src/containers/Accounts/withAccountsActions.js b/client/src/containers/Accounts/withAccountsActions.js
index baea3060e..d366ff664 100644
--- a/client/src/containers/Accounts/withAccountsActions.js
+++ b/client/src/containers/Accounts/withAccountsActions.js
@@ -9,7 +9,8 @@ import {
fetchAccount,
deleteBulkAccounts,
bulkActivateAccounts,
- bulkInactiveAccounts
+ bulkInactiveAccounts,
+ editAccount
} from 'store/accounts/accounts.actions';
const mapActionsToProps = (dispatch) => ({
@@ -23,6 +24,7 @@ const mapActionsToProps = (dispatch) => ({
requestDeleteBulkAccounts: (ids) => dispatch(deleteBulkAccounts({ ids })),
requestBulkActivateAccounts:(ids)=>dispatch(bulkActivateAccounts({ids})),
requestBulkInactiveAccounts:(ids)=>dispatch(bulkInactiveAccounts({ids})),
+ requestEditAccount:({id,form}) => dispatch(editAccount({id,form}))
});
export default connect(null, mapActionsToProps);
\ No newline at end of file
diff --git a/client/src/containers/Dialogs/AccountFormDialog.js b/client/src/containers/Dialogs/AccountFormDialog.js
index af3e24252..8830ea883 100644
--- a/client/src/containers/Dialogs/AccountFormDialog.js
+++ b/client/src/containers/Dialogs/AccountFormDialog.js
@@ -14,7 +14,7 @@ import * as Yup from 'yup';
import { useFormik } from 'formik';
import { FormattedMessage as T, useIntl } from 'react-intl';
-import { omit } from 'lodash';
+import { omit, pick } from 'lodash';
import { useQuery, queryCache } from 'react-query';
import Dialog from 'components/Dialog';
@@ -27,7 +27,6 @@ import Icon from 'components/Icon';
import ErrorMessage from 'components/ErrorMessage';
import { ListSelect } from 'components';
-
function AccountFormDialog({
name,
payload,
@@ -105,29 +104,39 @@ function AccountFormDialog({
if (payload.action === 'edit') {
requestEditAccount({
payload: payload.id,
- form: { ...omit(values, [...exclude, 'account_type_id']) },
- }).then((response) => {
- closeDialog(name);
- queryCache.refetchQueries('accounts-table', { force: true });
+ // form: { ...omit(values, [...exclude, 'account_type_id']) },
+ form: {
+ ...pick(values, [
+ ...exclude,
+ 'account_type_id',
+ 'name',
+ 'description',
+ ]),
+ },
+ })
+ .then((response) => {
+ closeDialog(name);
+ queryCache.refetchQueries('accounts-table', { force: true });
- AppToaster.show({
- message: formatMessage(
- { id: 'service_has_been_successful_edited', },
- {
- name: toastAccountName,
- service: formatMessage({ id: 'account' }),
- },
- ),
- intent: Intent.SUCCESS,
+ AppToaster.show({
+ message: formatMessage(
+ { id: 'service_has_been_successful_edited' },
+ {
+ name: toastAccountName,
+ service: formatMessage({ id: 'account' }),
+ },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ })
+ .catch((errors) => {
+ const errorsTransformed = transformApiErrors(errors);
+ setErrors({ ...errorsTransformed });
+ setSubmitting(false);
});
- }).catch((errors) => {
- const errorsTransformed = transformApiErrors(errors);
- setErrors({ ...errorsTransformed });
- setSubmitting(false);
- });
} else {
- requestSubmitAccount({ form: { ...omit(values, exclude) } }).then(
- (response) => {
+ requestSubmitAccount({ form: { ...omit(values, exclude) } })
+ .then((response) => {
closeDialog(name);
queryCache.refetchQueries('accounts-table', { force: true });
@@ -142,20 +151,24 @@ function AccountFormDialog({
intent: Intent.SUCCESS,
position: Position.BOTTOM,
});
- },
- ).catch((errors) => {
- const errorsTransformed = transformApiErrors(errors);
- setErrors({ ...errorsTransformed });
- setSubmitting(false);
- });
+ })
+ .catch((errors) => {
+ const errorsTransformed = transformApiErrors(errors);
+ setErrors({ ...errorsTransformed });
+ setSubmitting(false);
+ });
}
},
});
-
+
// Filtered accounts based on the given account type.
- const filteredAccounts = useMemo(() => accounts.filter((account) =>
- account.account_type_id === values.account_type_id
- ), [accounts, values.account_type_id]);
+ const filteredAccounts = useMemo(
+ () =>
+ accounts.filter(
+ (account) => account.account_type_id === values.account_type_id,
+ ),
+ [accounts, values.account_type_id],
+ );
// Filters accounts types items.
const filterAccountTypeItems = (query, accountType, _index, exactMatch) => {
@@ -307,7 +320,9 @@ function AccountFormDialog({
Classes.FILL,
)}
inline={true}
- helperText={}
+ helperText={
+
+ }
intent={
errors.account_type_id && touched.account_type_id && Intent.DANGER
}
diff --git a/client/src/containers/Expenses/ExpenseActionsBar.js b/client/src/containers/Expenses/ExpenseActionsBar.js
new file mode 100644
index 000000000..20b67839a
--- /dev/null
+++ b/client/src/containers/Expenses/ExpenseActionsBar.js
@@ -0,0 +1,145 @@
+import React, { useMemo, useCallback } from 'react';
+import Icon from 'components/Icon';
+import {
+ Button,
+ NavbarGroup,
+ Classes,
+ NavbarDivider,
+ MenuItem,
+ Menu,
+ Popover,
+ PopoverInteractionKind,
+ Position,
+ Intent,
+} from '@blueprintjs/core';
+import classNames from 'classnames';
+import { useRouteMatch, useHistory } from 'react-router-dom';
+import { FormattedMessage as T } from 'react-intl';
+
+import FilterDropdown from 'components/FilterDropdown';
+import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar';
+import withDialogActions from 'containers/Dialog/withDialogActions';
+
+import { If } from 'components';
+
+import withResourceDetail from 'containers/Resources/withResourceDetails';
+import withExpenses from 'containers/Expenses/withExpenses';
+import withExpensesActions from 'containers/Expenses/withExpensesActions';
+
+import { compose } from 'utils';
+
+function ExpenseActionsBar({
+ // #withResourceDetail
+ resourceFields,
+
+ //#withExpenses
+ expensesViews,
+ //#withExpensesActions
+ addExpensesTableQueries,
+
+ onFilterChanged,
+ selectedRows,
+ onBulkDelete,
+}) {
+ const { path } = useRouteMatch();
+ const history = useHistory();
+
+ const viewsMenuItems = expensesViews.map((view) => {
+ return (
+
+ );
+ });
+
+ const onClickNewExpense = useCallback(() => {
+ history.push('/expenses/new');
+ }, [history]);
+
+ const filterDropdown = FilterDropdown({
+ fields: resourceFields,
+ onFilterChange: (filterConditions) => {
+ addExpensesTableQueries({
+ filter_roles: filterConditions || '',
+ });
+ onFilterChanged && onFilterChanged(filterConditions);
+ },
+ });
+
+ const hasSelectedRows = useMemo(() => selectedRows.length > 0, [
+ selectedRows,
+ ]);
+
+ // Handle delete button click.
+ const handleBulkDelete = useCallback(() => {
+ onBulkDelete && onBulkDelete(selectedRows.map((r) => r.id));
+ }, [onBulkDelete, selectedRows]);
+
+ return (
+
+
+ {viewsMenuItems}}
+ minimal={true}
+ interactionKind={PopoverInteractionKind.HOVER}
+ position={Position.BOTTOM_LEFT}
+ >
+ }
+ text={}
+ rightIcon={'caret-down'}
+ />
+
+
+ }
+ text={}
+ onClick={onClickNewExpense}
+ />
+
+ }
+ />
+
+
+
+ }
+ text={}
+ intent={Intent.DANGER}
+ onClick={handleBulkDelete}
+ />
+
+
+ }
+ text={}
+ />
+ }
+ text={}
+ />
+
+
+ );
+}
+
+export default compose(
+ withDialogActions,
+ withResourceDetail(({ resourceFields }) => ({
+ resourceFields,
+ })),
+ withExpenses(({ expensesViews }) => ({
+ expensesViews,
+ })),
+ withExpensesActions,
+)(ExpenseActionsBar);
diff --git a/client/src/containers/Expenses/ExpenseDataTable.js b/client/src/containers/Expenses/ExpenseDataTable.js
new file mode 100644
index 000000000..7fd022fa6
--- /dev/null
+++ b/client/src/containers/Expenses/ExpenseDataTable.js
@@ -0,0 +1,261 @@
+import React, { useEffect, useCallback, useState, useMemo } from 'react';
+import {
+ Intent,
+ Button,
+ Classes,
+ Popover,
+ Tooltip,
+ Menu,
+ MenuItem,
+ MenuDivider,
+ Position,
+ Tag,
+} from '@blueprintjs/core';
+import { useParams } from 'react-router-dom';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import moment from 'moment';
+
+import Icon from 'components/Icon';
+import { compose } from 'utils';
+import { useUpdateEffect } from 'hooks';
+
+import LoadingIndicator from 'components/LoadingIndicator';
+import { If, Money } from 'components';
+import DataTable from 'components/DataTable';
+
+import withDialogActions from 'containers/Dialog/withDialogActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withViewDetails from 'containers/Views/withViewDetails';
+import withExpenses from 'containers/Expenses/withExpenses';
+import withExpensesActions from 'containers/Expenses/withExpensesActions';
+
+function ExpenseDataTable({
+ loading,
+
+ //#withExpenes
+ expenses,
+ expensesLoading,
+
+ // #withDashboardActions
+ changeCurrentView,
+ changePageSubtitle,
+ setTopbarEditView,
+
+ viewMeta,
+
+ onFetchData,
+ onEditExpense,
+ onDeleteExpense,
+ onPublishExpense,
+ onSelectedRowsChange,
+}) {
+ const { custom_view_id: customViewId } = useParams();
+ const [initialMount, setInitialMount] = useState(false);
+ const { formatMessage } = useIntl();
+
+ useUpdateEffect(() => {
+ if (!expensesLoading) {
+ setInitialMount(true);
+ }
+ }, [expensesLoading, setInitialMount]);
+
+ useEffect(() => {
+ if (customViewId) {
+ changeCurrentView(customViewId);
+ setTopbarEditView(customViewId);
+ }
+ changePageSubtitle(customViewId && viewMeta ? viewMeta.name : '');
+ }, [
+ customViewId,
+ changeCurrentView,
+ changePageSubtitle,
+ setTopbarEditView,
+ viewMeta,
+ ]);
+
+ const handlePublishExpense = useCallback(
+ (expense) => () => {
+ onPublishExpense && onPublishExpense(expense);
+ },
+ [onPublishExpense],
+ );
+
+ const handleEditExpense = useCallback(
+ (expense) => () => {
+ onEditExpense && onEditExpense(expense);
+ },
+ [onEditExpense],
+ );
+
+ const handleDeleteExpense = useCallback(
+ (expense) => () => {
+ onDeleteExpense && onDeleteExpense(expense);
+ },
+ [onDeleteExpense],
+ );
+
+ const actionMenuList = useCallback(
+ (expense) => (
+
+ ),
+ [handleEditExpense, handleDeleteExpense, handlePublishExpense],
+ );
+ console.log(Object.values(expenses), 'ER');
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'payment_date',
+ Header: formatMessage({ id: 'payment_date' }),
+ accessor: () => moment().format('YYYY-MM-DD'),
+ width: 150,
+ className: 'payment_date',
+ },
+ {
+ id: 'beneficiary',
+ Header: formatMessage({ id: 'beneficiary' }),
+ // accessor: 'beneficiary',
+ width: 150,
+ className: 'beneficiary',
+ },
+ {
+ id: 'total_amount',
+ Header: formatMessage({ id: 'full_amount' }),
+ accessor: (r) => ,
+ disableResizing: true,
+ className: 'total_amount',
+ width: 150,
+ },
+ {
+ id: 'payment_account_id',
+ Header: formatMessage({ id: 'payment_account' }),
+ accessor: 'payment_account.name',
+ className: 'payment_account',
+ width: 150,
+ },
+ {
+ id: 'expense_account_id',
+ Header: formatMessage({ id: 'expense_account' }),
+ accessor:'expense_account_id',
+ width: 150,
+ className: 'expense_account',
+ },
+
+ {
+ id: 'publish',
+ Header: formatMessage({ id: 'publish' }),
+ accessor: (r) => {
+ return !r.published ? (
+
+
+
+ ) : (
+
+
+
+ );
+ },
+ disableResizing: true,
+ width: 100,
+ className: 'publish',
+ },
+ {
+ id: 'description',
+ Header: formatMessage({ id: 'description' }),
+ accessor: (row) => (
+
+
+
+
+
+ ),
+ disableSorting: true,
+ width: 150,
+ className: 'description',
+ },
+ {
+ id: 'actions',
+ Header: '',
+ Cell: ({ cell }) => (
+
+ } />
+
+ ),
+ className: 'actions',
+ width: 50,
+ },
+ ],
+ [actionMenuList, formatMessage],
+ );
+
+ const handleDataTableFetchData = useCallback(
+ (...args) => {
+ onFetchData && onFetchData(...args);
+ },
+ [onFetchData],
+ );
+
+ const handleSelectedRowsChange = useCallback(
+ (selectedRows) => {
+ onSelectedRowsChange &&
+ onSelectedRowsChange(selectedRows.map((s) => s.original));
+ },
+ [onSelectedRowsChange],
+ );
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withDialogActions,
+ withDashboardActions,
+ withExpensesActions,
+ withExpenses(({ expenses, expensesLoading }) => ({
+ expenses,
+ expensesLoading,
+ })),
+ withViewDetails,
+)(ExpenseDataTable);
diff --git a/client/src/containers/Expenses/ExpenseFooter.js b/client/src/containers/Expenses/ExpenseFooter.js
new file mode 100644
index 000000000..2540cd66c
--- /dev/null
+++ b/client/src/containers/Expenses/ExpenseFooter.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import { Intent, Button } from '@blueprintjs/core';
+import { FormattedMessage as T } from 'react-intl';
+
+function ExpenseFooter({
+ formik: { isSubmitting },
+ onSubmitClick,
+ onCancelClick,
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default ExpenseFooter;
diff --git a/client/src/containers/Expenses/ExpenseForm.js b/client/src/containers/Expenses/ExpenseForm.js
index 3b33cd7c0..4b198b0c6 100644
--- a/client/src/containers/Expenses/ExpenseForm.js
+++ b/client/src/containers/Expenses/ExpenseForm.js
@@ -1,41 +1,329 @@
-import React, {useEffect} from 'react';
-import { useAsync } from 'react-use';
-import {useParams} from 'react-router-dom';
-import Connector from 'connectors/ExpenseForm.connector';
-import DashboardInsider from 'components/Dashboard/DashboardInsider';
-import ExpenseForm from 'components/Expenses/ExpenseForm';
-import { useIntl } from 'react-intl';
+import React, {
+ useMemo,
+ useState,
+ useEffect,
+ useRef,
+ useCallback,
+} from 'react';
+import * as Yup from 'yup';
+import { useFormik } from 'formik';
+import moment from 'moment';
+import { Intent, FormGroup, TextArea } from '@blueprintjs/core';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+import { pick } from 'lodash';
+import { useQuery } from 'react-query';
-function ExpenseFormContainer({
- fetchAccounts,
- fetchCurrencies,
- accounts,
+import ExpenseFormHeader from './ExpenseFormHeader';
+import ExpenseTable from './ExpenseTable';
+import ExpenseFooter from './ExpenseFooter';
+
+import withExpensesActions from 'containers/Expenses/withExpensesActions';
+import withExpneseDetail from 'containers/Expenses/withExpenseDetail';
+import withAccountsActions from 'containers/Accounts/withAccountsActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withMediaActions from 'containers/Media/withMediaActions';
+
+import AppToaster from 'components/AppToaster';
+import Dragzone from 'components/Dragzone';
+
+import useMedia from 'hooks/useMedia';
+import { compose } from 'utils';
+
+function ExpenseForm({
+ // #withMedia
+ requestSubmitMedia,
+ requestDeleteMedia,
+
+ //#withExpensesActions
+ requestSubmitExpense,
+ requestEditExpense,
+ requestFetchExpensesTable,
+ // #withDashboard
changePageTitle,
- submitExpense,
- editExpense,
- currencies,
-}) {
- const { id } = useParams();
- const { formatMessage } = useIntl();
- useEffect(() => {
- if (id) {
- changePageTitle(formatMessage({id:'edit_expense_details'}));
- } else {
- changePageTitle(formatMessage({id:'new_expense'}));
- }
- }, [id,changePageTitle,formatMessage]);
+ changePageSubtitle,
- const fetchHook = useAsync(async () => {
- await Promise.all([
- fetchAccounts(),
- fetchCurrencies(),
- ]);
+ //#withExpenseDetail
+ expenseDetail,
+
+ // #own Props
+ expenseId,
+ onFormSubmit,
+ onCancelForm,
+}) {
+ const { formatMessage } = useIntl();
+ const [payload, setPayload] = useState({});
+ const {
+ setFiles,
+ saveMedia,
+ deletedFiles,
+ setDeletedFiles,
+ deleteMedia,
+ } = useMedia({
+ saveCallback: requestSubmitMedia,
+ deleteCallback: requestDeleteMedia,
});
+
+ const handleDropFiles = useCallback((_files) => {
+ setFiles(_files.filter((file) => file.uploaded === false));
+ }, []);
+
+ const savedMediaIds = useRef([]);
+ const clearSavedMediaIds = () => {
+ savedMediaIds.current = [];
+ };
+
+ useEffect(() => {
+ if (expenseDetail && expenseDetail.id) {
+ changePageTitle(formatMessage({ id: 'edit_expense' }));
+ changePageSubtitle(`No. ${expenseDetail.payment_account_id}`);
+ } else {
+ changePageTitle(formatMessage({ id: 'new_expense' }));
+ }
+ }, [changePageTitle, changePageSubtitle, expenseDetail, formatMessage]);
+
+ const validationSchema = Yup.object().shape({
+ beneficiary: Yup.string()
+ // .required()
+ .label(formatMessage({ id: 'beneficiary' })),
+ payment_account_id: Yup.string()
+ .required()
+ .label(formatMessage({ id: 'payment_account_' })),
+ payment_date: Yup.date()
+ .required()
+ .label(formatMessage({ id: 'payment_date_' })),
+ reference_no: Yup.string(),
+ currency_code: Yup.string().label(formatMessage({ id: 'currency_code' })),
+ description: Yup.string()
+ .trim()
+ .label(formatMessage({ id: 'description' })),
+
+ publish: Yup.boolean().label(formatMessage({ id: 'publish' })),
+
+ categories: Yup.array().of(
+ Yup.object().shape({
+ index: Yup.number().nullable(),
+ amount: Yup.number().nullable(),
+ expense_account_id: Yup.number().nullable(),
+ description: Yup.string().nullable(),
+ }),
+ ),
+ });
+
+ const saveInvokeSubmit = useCallback(
+ (payload) => {
+ onFormSubmit && onFormSubmit(payload);
+ },
+ [onFormSubmit],
+ );
+
+ const defaultCategory = useMemo(
+ () => ({
+ index: 0,
+ amount: 0,
+ expense_account_id: null,
+ description: '',
+ }),
+ [],
+ );
+
+ const defaultInitialValues = useMemo(
+ () => ({
+ payment_account_id: '',
+ beneficiary: '',
+ payment_date: moment(new Date()).format('YYYY-MM-DD'),
+ description: '',
+ reference_no: '',
+ currency_code: '',
+ categories: [
+ defaultCategory,
+ defaultCategory,
+ defaultCategory,
+ defaultCategory,
+ ],
+ }),
+ [defaultCategory],
+ );
+
+
+ const initialValues = useMemo(
+ () => ({
+ ...(expenseDetail
+ ? {
+ ...pick(expenseDetail, Object.keys(defaultInitialValues)),
+ categories: expenseDetail.categories.map((category) => ({
+ ...pick(category, Object.keys(defaultCategory)),
+ })),
+ }
+ : {
+ ...defaultInitialValues,
+ }),
+ }),
+ [expenseDetail, defaultInitialValues, defaultCategory],
+ );
+
+ const initialAttachmentFiles = useMemo(() => {
+ return expenseDetail && expenseDetail.media
+ ? expenseDetail.media.map((attach) => ({
+ preview: attach.attachment_file,
+ uploaded: true,
+ metadata: { ...attach },
+ }))
+ : [];
+ }, [expenseDetail]);
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ validationSchema,
+ initialValues: {
+ ...initialValues,
+ },
+ onSubmit: async (values, { setSubmitting, setErrors, resetForm }) => {
+ const categories = values.categories.filter(
+ (category) => category.amount || category.index,
+ );
+
+ const form = {
+ ...values,
+ published: payload.publish,
+ categories,
+ };
+
+ const saveExpense = (mdeiaIds) =>
+ new Promise((resolve, reject) => {
+ const requestForm = { ...form, media_ids: mdeiaIds };
+
+ if (expenseDetail && expenseDetail.id) {
+ requestEditExpense(expenseDetail.id, requestForm)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage(
+ { id: 'the_expense_has_been_successfully_edited' },
+ { number: values.payment_account_id },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ saveInvokeSubmit({ action: 'update', ...payload });
+ clearSavedMediaIds([]);
+ resetForm();
+ resolve(response);
+ })
+ .catch((errors) => {
+ if (errors.find((e) => e.type === 'TOTAL.AMOUNT.EQUALS.ZERO')) {
+ }
+ setErrors(
+ AppToaster.show({
+ message: formatMessage({
+ id: 'total_amount_equals_zero',
+ }),
+ intent: Intent.DANGER,
+ }),
+ );
+ setSubmitting(false);
+ });
+ } else {
+ requestSubmitExpense(requestForm)
+ .then((response) => {
+ AppToaster.show({
+ message: formatMessage(
+ { id: 'the_expense_has_been_successfully_created' },
+ { number: values.payment_account_id },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ setSubmitting(false);
+ saveInvokeSubmit({ action: 'new', ...payload });
+ clearSavedMediaIds();
+ resetForm();
+ resolve(response);
+ })
+ .catch((errors) => {
+ setSubmitting(false);
+ });
+ }
+ });
+
+ Promise.all([saveMedia(), deleteMedia()])
+ .then(([savedMediaResponses]) => {
+ const mediaIds = savedMediaResponses.map((res) => res.data.media.id);
+ savedMediaIds.current = mediaIds;
+ return savedMediaResponses;
+ })
+ .then(() => {
+ return saveExpense(savedMediaIds.current);
+ });
+ },
+ });
+
+ const handleSubmitClick = useCallback(
+ (payload) => {
+ setPayload(payload);
+ formik.handleSubmit();
+ },
+ [setPayload, formik],
+ );
+
+ const handleCancelClick = useCallback(
+ (payload) => {
+ onCancelForm && onCancelForm(payload);
+ },
+ [onCancelForm],
+ );
+
+ const handleDeleteFile = useCallback(
+ (_deletedFiles) => {
+ _deletedFiles.forEach((deletedFile) => {
+ if (deletedFile.uploaded && deletedFile.metadata.id) {
+ setDeletedFiles([...deletedFiles, deletedFile.metadata.id]);
+ }
+ });
+ },
+ [setDeletedFiles, deletedFiles],
+ );
+ const fetchHook = useQuery('expense-form', () => requestFetchExpensesTable());
+
return (
-
-
-
+
+
+
+
);
}
-export default Connector(ExpenseFormContainer);
\ No newline at end of file
+export default compose(
+ withExpensesActions,
+ withAccountsActions,
+ withDashboardActions,
+ withMediaActions,
+ withExpneseDetail,
+)(ExpenseForm);
diff --git a/client/src/containers/Expenses/ExpenseFormHeader.js b/client/src/containers/Expenses/ExpenseFormHeader.js
new file mode 100644
index 000000000..c5cdafc94
--- /dev/null
+++ b/client/src/containers/Expenses/ExpenseFormHeader.js
@@ -0,0 +1,262 @@
+import React, { useMemo, useCallback, useState } from 'react';
+import {
+ InputGroup,
+ FormGroup,
+ Intent,
+ Position,
+ MenuItem,
+ Classes,
+} from '@blueprintjs/core';
+import { DateInput } from '@blueprintjs/datetime';
+import { FormattedMessage as T } from 'react-intl';
+import { Row, Col } from 'react-grid-system';
+import moment from 'moment';
+import { momentFormatter, compose } from 'utils';
+
+import classNames from 'classnames';
+import Icon from 'components/Icon';
+import ErrorMessage from 'components/ErrorMessage';
+import { ListSelect } from 'components';
+import withCurrencies from 'containers/Currencies/withCurrencies';
+import withAccounts from 'containers/Accounts/withAccounts';
+
+function ExpenseFormHeader({
+ formik: { errors, touched, setFieldValue, getFieldProps, values },
+ currenciesList,
+ accounts,
+}) {
+ const [selectedItems, setSelectedItems] = useState({});
+
+ const handleDateChange = useCallback(
+ (date) => {
+ const formatted = moment(date).format('YYYY-MM-DD');
+ setFieldValue('payment_date', formatted);
+ },
+ [setFieldValue],
+ );
+
+ const infoIcon = useMemo(() => , []);
+
+ const requiredSpan = useMemo(() => *, []);
+
+ const currencyCodeRenderer = useCallback((item, { handleClick }) => {
+ return (
+
+ );
+ }, []);
+
+ // Filters Currency code.
+ const filterCurrencyCode = (query, currency, _index, exactMatch) => {
+ const normalizedTitle = currency.currency_code.toLowerCase();
+ const normalizedQuery = query.toLowerCase();
+
+ if (exactMatch) {
+ return normalizedTitle === normalizedQuery;
+ } else {
+ return (
+ `${currency.currency_code} ${normalizedTitle}`.indexOf(
+ normalizedQuery,
+ ) >= 0
+ );
+ }
+ };
+
+ // Account item of select accounts field.
+ const accountItem = (item, { handleClick }) => {
+ return (
+
+ );
+ };
+
+ // Filters accounts items.
+ const filterAccountsPredicater = useCallback(
+ (query, account, _index, exactMatch) => {
+ const normalizedTitle = account.name.toLowerCase();
+ const normalizedQuery = query.toLowerCase();
+
+ if (exactMatch) {
+ return normalizedTitle === normalizedQuery;
+ } else {
+ return (
+ `${account.code} ${normalizedTitle}`.indexOf(normalizedQuery) >= 0
+ );
+ }
+ },
+ [],
+ );
+
+ // Handles change account.
+ const onChangeAccount = useCallback(
+ (account) => {
+ setFieldValue('payment_account_id', account.id);
+ },
+ [setFieldValue],
+ );
+
+ const onItemsSelect = useCallback(
+ (filedName) => {
+ return (filed) => {
+ setSelectedItems({
+ ...selectedItems,
+ [filedName]: filed,
+ });
+ setFieldValue(filedName, filed.currency_code);
+ };
+ },
+ [setFieldValue, selectedItems],
+ );
+
+ return (
+
+
+
+ }
+ className={classNames('form-group--select-list', Classes.FILL)}
+ labelInfo={infoIcon}
+ intent={errors.beneficiary && touched.beneficiary && Intent.DANGER}
+ helperText={
+
+ }
+ >
+ }
+ // itemRenderer={}
+ // itemPredicate={}
+ popoverProps={{ minimal: true }}
+ // onItemSelect={}
+ selectedItem={values.beneficiary}
+ // selectedItemProp={'id'}
+ defaultText={}
+ labelProp={'beneficiary'}
+ />
+
+
+
+ }
+ className={classNames(
+ 'form-group--payment_account',
+ 'form-group--select-list',
+ Classes.FILL,
+ )}
+ labelInfo={requiredSpan}
+ intent={
+ errors.payment_account_id &&
+ touched.payment_account_id &&
+ Intent.DANGER
+ }
+ helperText={
+
+ }
+ >
+ }
+ itemRenderer={accountItem}
+ itemPredicate={filterAccountsPredicater}
+ popoverProps={{ minimal: true }}
+ onItemSelect={onChangeAccount}
+ selectedItem={values.payment_account_id}
+ selectedItemProp={'id'}
+ defaultText={}
+ labelProp={'name'}
+ />
+
+
+
+
+
+ }
+ labelInfo={infoIcon}
+ className={classNames('form-group--select-list', Classes.FILL)}
+ intent={
+ errors.payment_date && touched.payment_date && Intent.DANGER
+ }
+ helperText={
+
+ }
+ minimal={true}
+ >
+
+
+
+
+ }
+ className={classNames(
+ 'form-group--select-list',
+ 'form-group--currency',
+ Classes.FILL,
+ )}
+ intent={
+ errors.currency_code && touched.currency_code && Intent.DANGER
+ }
+ helperText={
+
+ }
+ >
+ }
+ itemRenderer={currencyCodeRenderer}
+ itemPredicate={filterCurrencyCode}
+ popoverProps={{ minimal: true }}
+ onItemSelect={onItemsSelect('currency_code')}
+ selectedItem={values.currency_code}
+ selectedItemProp={'currency_code'}
+ defaultText={}
+ labelProp={'currency_code'}
+ />
+
+
+
+
+ }
+ className={'form-group--ref_no'}
+ intent={
+ errors.reference_no && touched.reference_no && Intent.DANGER
+ }
+ helperText={
+
+ }
+ minimal={true}
+ >
+
+
+
+
+
+ );
+}
+
+export default compose(
+ withAccounts(({ accounts }) => ({
+ accounts,
+ })),
+ withCurrencies(({ currenciesList }) => ({
+ currenciesList,
+ })),
+)(ExpenseFormHeader);
diff --git a/client/src/containers/Expenses/ExpenseTable.js b/client/src/containers/Expenses/ExpenseTable.js
new file mode 100644
index 000000000..e552696e7
--- /dev/null
+++ b/client/src/containers/Expenses/ExpenseTable.js
@@ -0,0 +1,239 @@
+import React, { useState, useMemo, useEffect, useCallback } from 'react';
+import { Button, Intent } from '@blueprintjs/core';
+import { FormattedMessage as T, useIntl } from 'react-intl';
+
+import DataTable from 'components/DataTable';
+import Icon from 'components/Icon';
+import { compose, formattedAmount } from 'utils';
+import {
+ AccountsListFieldCell,
+ MoneyFieldCell,
+ InputGroupCell,
+} from 'components/DataTableCells';
+import { omit } from 'lodash';
+import withAccounts from 'containers/Accounts/withAccounts';
+
+function ExpenseTable({
+ // #withAccounts
+ accounts,
+
+ // #ownPorps
+ onClickRemoveRow,
+ onClickAddNewRow,
+ defaultRow,
+ initialValues,
+ formik: { errors, values, setFieldValue },
+}) {
+ const [rows, setRow] = useState([]);
+ const { formatMessage } = useIntl();
+
+ useEffect(() => {
+ setRow([
+ ...initialValues.categories.map((e) => ({ ...e, rowType: 'editor' })),
+ defaultRow,
+ defaultRow,
+ ]);
+ }, [initialValues, defaultRow]);
+
+ // Handles update datatable data.
+ const handleUpdateData = useCallback(
+ (rowIndex, columnId, value) => {
+ const newRows = rows.map((row, index) => {
+ if (index === rowIndex) {
+ return { ...rows[rowIndex], [columnId]: value };
+ }
+ return { ...row };
+ });
+ setRow(newRows);
+ setFieldValue(
+ 'categories',
+ newRows.map((row) => ({
+ ...omit(row, ['rowType']),
+ })),
+ );
+ },
+ [rows, setFieldValue],
+ );
+
+ // Handles click remove datatable row.
+ const handleRemoveRow = useCallback(
+ (rowIndex) => {
+ const removeIndex = parseInt(rowIndex, 10);
+ const newRows = rows.filter((row, index) => index !== removeIndex);
+
+ setRow([...newRows]);
+ setFieldValue(
+ 'categories',
+ newRows
+ .filter((row) => row.rowType === 'editor')
+ .map((row) => ({ ...omit(row, ['rowType']) })),
+ );
+ onClickRemoveRow && onClickRemoveRow(removeIndex);
+ },
+ [rows, setFieldValue, onClickRemoveRow],
+ );
+
+ // Actions cell renderer.
+ const ActionsCellRenderer = ({
+ row: { index },
+ column: { id },
+ cell: { value: initialValue },
+ data,
+ payload,
+ }) => {
+ if (data.length <= index + 2) {
+ return '';
+ }
+ const onClickRemoveRole = () => {
+ payload.removeRow(index);
+ };
+ return (
+ }
+ iconSize={14}
+ className="ml2"
+ minimal={true}
+ intent={Intent.DANGER}
+ onClick={onClickRemoveRole}
+ />
+ );
+ };
+
+ // Total text cell renderer.
+ const TotalExpenseCellRenderer = (chainedComponent) => (props) => {
+ if (props.data.length === props.row.index + 2) {
+ return (
+
+ {formatMessage({ id: 'total_currency' }, { currency: 'USD' })}
+
+ );
+ }
+ return chainedComponent(props);
+ };
+
+ const NoteCellRenderer = (chainedComponent) => (props) => {
+ if (props.data.length === props.row.index + 2) {
+ return '';
+ }
+ return chainedComponent(props);
+ };
+
+ const TotalAmountCellRenderer = (chainedComponent, type) => (props) => {
+ if (props.data.length === props.row.index + 2) {
+ const total = props.data.reduce((total, entry) => {
+ const amount = parseInt(entry[type], 10);
+ const computed = amount ? total + amount : total;
+
+ return computed;
+ }, 0);
+
+ return {formattedAmount(total, 'USD')};
+ }
+ return chainedComponent(props);
+ };
+
+ const columns = useMemo(
+ () => [
+ {
+ Header: '#',
+ accessor: 'index',
+ Cell: ({ row: { index } }) => {index + 1},
+ className: 'index',
+ width: 40,
+ disableResizing: true,
+ disableSortBy: true,
+ },
+ {
+ Header: formatMessage({ id: 'expense_category' }),
+ id: 'expense_account_id',
+ accessor: 'expense_account_id',
+ Cell: TotalExpenseCellRenderer(AccountsListFieldCell),
+ className: 'expense_account_id',
+ disableSortBy: true,
+ disableResizing: true,
+ width: 250,
+ },
+ {
+ Header: formatMessage({ id: 'amount_currency' }, { currency: 'USD' }),
+ accessor: 'amount',
+ Cell: TotalAmountCellRenderer(MoneyFieldCell, 'amount'),
+ disableSortBy: true,
+ disableResizing: true,
+ width: 150,
+ },
+
+ {
+ Header: formatMessage({ id: 'description' }),
+ accessor: 'description',
+ Cell: NoteCellRenderer(InputGroupCell),
+ disableSortBy: true,
+ className: 'description',
+ },
+ {
+ Header: '',
+ accessor: 'action',
+ Cell: ActionsCellRenderer,
+ className: 'actions',
+ disableSortBy: true,
+ disableResizing: true,
+ width: 45,
+ },
+ ],
+ [formatMessage],
+ );
+
+ // Handles click new line.
+ const onClickNewRow = useCallback(() => {
+ setRow([...rows, { ...defaultRow, rowType: 'editor' }]);
+ onClickAddNewRow && onClickAddNewRow();
+ }, [defaultRow, rows, onClickAddNewRow]);
+
+ const rowClassNames = useCallback(
+ (row) => ({
+ 'row--total': rows.length === row.index + 2,
+ }),
+ [rows],
+ );
+
+
+ return (
+
+ );
+}
+
+export default compose(
+ withAccounts(({ accounts }) => ({
+ accounts,
+ })),
+
+)(ExpenseTable);
diff --git a/client/src/containers/Expenses/ExpenseViewTabs.js b/client/src/containers/Expenses/ExpenseViewTabs.js
new file mode 100644
index 000000000..4fdd3e956
--- /dev/null
+++ b/client/src/containers/Expenses/ExpenseViewTabs.js
@@ -0,0 +1,124 @@
+import React, { useEffect } from 'react';
+import { useHistory } from 'react-router';
+import {
+ Alignment,
+ Navbar,
+ NavbarGroup,
+ Tabs,
+ Tab,
+ Button,
+} from '@blueprintjs/core';
+import { useParams, withRouter } from 'react-router-dom';
+import { Link } from 'react-router-dom';
+import { connect } from 'react-redux';
+import { FormattedMessage as T } from 'react-intl';
+
+import { useUpdateEffect } from 'hooks';
+import Icon from 'components/Icon';
+
+import withExpenses from './withExpenses';
+import withExpensesActions from './withExpensesActions';
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+
+import { compose } from 'utils';
+
+function ExpenseViewTabs({
+ //#withExpenses
+ expensesViews,
+
+ //#withExpensesActions
+ addExpensesTableQueries,
+
+ // #withDashboardActions
+ setTopbarEditView,
+
+ // #ownProps
+ customViewChanged,
+ onViewChanged,
+}) {
+ const history = useHistory();
+ const { custom_view_id: customViewId } = useParams();
+
+ const handleClickNewView = () => {
+ setTopbarEditView(null);
+ history.push('/custom_views/expenses/new');
+ };
+
+ const handleViewLinkClick = () => {
+ setTopbarEditView(customViewId);
+ };
+
+ useUpdateEffect(() => {
+ customViewChanged && customViewChanged(customViewId);
+
+ addExpensesTableQueries({
+ custom_view_id: customViewId || null,
+ });
+ onViewChanged && onViewChanged(customViewId);
+ }, [customViewId]);
+
+ useEffect(() => {
+ addExpensesTableQueries({
+ custom_view_id: customViewId,
+ });
+ }, [customViewId, addExpensesTableQueries]);
+
+ const tabs = expensesViews.map((view) => {
+ const baseUrl = '/expenses/new';
+ const link = (
+
+ {view.name}
+
+ );
+ return ;
+ });
+
+ return (
+
+
+
+
+
+
+ }
+ />
+ {tabs}
+ }
+ onClick={handleClickNewView}
+ minimal={true}
+ />
+
+
+
+ );
+}
+
+const mapStateToProps = (state, ownProps) => ({
+ // Mapping view id from matched route params.
+ viewId: ownProps.match.params.custom_view_id,
+});
+
+const withExpensesViewTabs = connect(mapStateToProps);
+
+export default compose(
+ withRouter,
+ withExpensesViewTabs,
+ withExpenses(({ expensesViews }) => ({
+ expensesViews,
+ })),
+ withExpensesActions,
+ withDashboardActions,
+)(ExpenseViewTabs);
diff --git a/client/src/containers/Expenses/Expenses.js b/client/src/containers/Expenses/Expenses.js
new file mode 100644
index 000000000..941700cb7
--- /dev/null
+++ b/client/src/containers/Expenses/Expenses.js
@@ -0,0 +1,69 @@
+import React, { useCallback } from 'react';
+import { useParams, useHistory } from 'react-router-dom';
+import { useQuery } from 'react-query';
+
+import ExpenseForm from './ExpenseForm';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import withAccountsActions from 'containers/Accounts/withAccountsActions';
+import withExpensesActions from 'containers/Expenses/withExpensesActions';
+import withCurrenciesActions from 'containers/Currencies/withCurrenciesActions';
+
+import { compose } from 'utils';
+
+function Expenses({
+ //#withwithAccountsActions
+ requestFetchAccounts,
+
+ //#withExpensesActions
+ requestFetchExpense,
+ // #wihtCurrenciesActions
+ requestFetchCurrencies,
+}) {
+ const history = useHistory();
+ const { id } = useParams();
+
+ const fetchAccounts = useQuery('accounts-expense-list', (key) =>
+ requestFetchAccounts(),
+ );
+
+ const fetchExpense = useQuery(id && ['expense', id], (key, expense_Id) =>
+ requestFetchExpense(expense_Id),
+ );
+
+ const fetchCurrencies = useQuery('currencies-expense-list', () =>
+ requestFetchCurrencies(),
+ );
+ const handleFormSubmit = useCallback(
+ (payload) => {
+ payload.redirect && history.push('/expenses-list');
+ },
+ [history],
+ );
+
+ const handleCancel = useCallback(() => {
+ history.push('/expenses-list');
+ }, [history]);
+
+ return (
+
+
+
+ );
+}
+
+export default compose(
+ withAccountsActions,
+ withCurrenciesActions,
+ withExpensesActions,
+)(Expenses);
diff --git a/client/src/containers/Expenses/ExpensesList.js b/client/src/containers/Expenses/ExpensesList.js
index 7d8622f31..430d14896 100644
--- a/client/src/containers/Expenses/ExpensesList.js
+++ b/client/src/containers/Expenses/ExpensesList.js
@@ -1,81 +1,243 @@
-import React, { useEffect, useState } from 'react';
-import { useAsync } from 'react-use';
+import React, { useEffect, useState, useMemo, useCallback } from 'react';
+import { Route, Switch, useHistory, useParams } from 'react-router-dom';
+import { useQuery } from 'react-query';
import { Alert, Intent } from '@blueprintjs/core';
-import DashboardInsider from 'components/Dashboard/DashboardInsider';
-import DashboardPageContent from 'components/Dashboard/DashboardPageContent';
-import ExpensesActionsBar from 'components/Expenses/ExpensesActionsBar';
-import ExpensesViewsTabs from 'components/Expenses/ExpensesViewsTabs';
-import ExpensesTable from 'components/Expenses/ExpensesTable';
-import connector from 'connectors/ExpensesList.connector';
import AppToaster from 'components/AppToaster';
import { FormattedMessage as T, useIntl } from 'react-intl';
+import DashboardPageContent from 'components/Dashboard/DashboardPageContent';
+import DashboardInsider from 'components/Dashboard/DashboardInsider';
+
+import ExpenseViewTabs from 'containers/Expenses/ExpenseViewTabs';
+import ExpenseDataTable from 'containers/Expenses/ExpenseDataTable';
+import ExpenseActionsBar from 'containers/Expenses/ExpenseActionsBar';
+
+import withDashboardActions from 'containers/Dashboard/withDashboardActions';
+import withExpensesActions from 'containers/Expenses/withExpensesActions';
+import withViewsActions from 'containers/Views/withViewsActions';
+
+import { compose } from 'utils';
function ExpensesList({
- fetchExpenses,
- deleteExpense,
- // fetchViews,
- expenses,
- getResourceViews,
- changePageTitle
+ // #withDashboardActions
+ changePageTitle,
+
+ // #withViewsActions
+ requestFetchResourceViews,
+
+ //#withExpensesActions
+ requestFetchExpensesTable,
+ requestDeleteExpense,
+ requestPublishExpense,
+ requestDeleteBulkExpenses,
+ addExpensesTableQueries,
+ requestFetchExpense,
}) {
- const {formatMessage} =useIntl()
- useEffect(() => {
- changePageTitle(formatMessage({id:'expenses_list'}));
- }, [changePageTitle,formatMessage]);
+ const history = useHistory();
+ const { id } = useParams();
+ const { formatMessage } = useIntl();
- const [deleteExpenseState, setDeleteExpense] = useState();
+ const [deleteExpense, setDeleteExpense] = useState(false);
+ const [selectedRows, setSelectedRows] = useState([]);
+ const [bulkDelete, setBulkDelete] = useState(false);
- const handleDeleteExpense = expense => {
- setDeleteExpense(expense);
- };
- const handleCancelAccountDelete = () => {
- setDeleteExpense(false);
- };
-
- const handleConfirmAccountDelete = () => {
- deleteExpense(deleteExpenseState.id).then(() => {
- setDeleteExpense(false);
- AppToaster.show({
- message: formatMessage({id:'the_expense_has_been_successfully_deleted'})
- });
- });
- };
-
- const fetchHook = useAsync(async () => {
- await Promise.all([
- fetchExpenses()
- // getResourceViews('expenses'),
- ]);
+ const fetchViews = useQuery('expenses-resource-views', () => {
+ return requestFetchResourceViews('expenses');
});
+ const fetchExpenses = useQuery('expenses-table', () =>
+ requestFetchExpensesTable(),
+ );
+
+ useEffect(() => {
+ changePageTitle(formatMessage({ id: 'expenses_list' }));
+ }, [changePageTitle, formatMessage]);
+
+ // Handle delete expense click.
+
+ const handleDeleteExpense = useCallback(
+ (expnese) => {
+ setDeleteExpense(expnese);
+ },
+ [setDeleteExpense],
+ );
+
+ // Handle cancel expense journal.
+
+ const handleCancelExpenseDelete = useCallback(() => {
+ setDeleteExpense(false);
+ }, [setDeleteExpense]);
+
+ // Handle confirm delete expense.
+ const handleConfirmExpenseDelete = useCallback(() => {
+ requestDeleteExpense(deleteExpense.id).then(() => {
+ AppToaster.show({
+ message: formatMessage(
+ {
+ id: 'the_expense_has_been_successfully_deleted',
+ },
+ {
+ number: deleteExpense.payment_account_id,
+ },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ setDeleteExpense(false);
+ });
+ }, [deleteExpense, requestDeleteExpense, formatMessage]);
+
+ // Calculates the selected rows count.
+ const selectedRowsCount = useMemo(() => Object.values(selectedRows).length, [
+ selectedRows,
+ ]);
+
+ const handleBulkDelete = useCallback(
+ (accountsIds) => {
+ setBulkDelete(accountsIds);
+ },
+ [setBulkDelete],
+ );
+
+ // Handle confirm journals bulk delete.
+ const handleConfirmBulkDelete = useCallback(() => {
+ requestDeleteBulkExpenses(bulkDelete)
+ .then(() => {
+ AppToaster.show({
+ message: formatMessage(
+ {
+ id: 'the_expenses_has_been_successfully_deleted',
+ },
+ {
+ count: selectedRowsCount,
+ },
+ ),
+ intent: Intent.SUCCESS,
+ });
+ setBulkDelete(false);
+ })
+ .catch((error) => {
+ setBulkDelete(false);
+ });
+ }, [requestDeleteBulkExpenses, bulkDelete, formatMessage, selectedRowsCount]);
+
+ // Handle cancel bulk delete alert.
+ const handleCancelBulkDelete = useCallback(() => {
+ setBulkDelete(false);
+ }, []);
+
+ const handleEidtExpense = useCallback(
+ (expense) => {
+ history.push(`/expenses/${expense.id}/edit`);
+ },
+ [history],
+ );
+
+ // Handle filter change to re-fetch data-table.
+ const handleFilterChanged = useCallback(() => {}, []);
+
+ // Handle fetch data of manual jouranls datatable.
+ const handleFetchData = useCallback(
+ ({ pageIndex, pageSize, sortBy }) => {
+ addExpensesTableQueries({
+ ...(sortBy.length > 0
+ ? {
+ column_sort_by: sortBy[0].id,
+ sort_order: sortBy[0].desc ? 'desc' : 'asc',
+ }
+ : {}),
+ });
+ },
+ [addExpensesTableQueries],
+ );
+
+ const handlePublishExpense = useCallback(
+ (expense) => {
+ requestPublishExpense(expense.id).then(() => {
+ AppToaster.show({
+ message: formatMessage({ id: 'the_expense_id_has_been_published' }),
+ });
+ });
+ },
+ [requestPublishExpense, formatMessage],
+ );
+
+ // Handle selected rows change.
+ const handleSelectedRowsChange = useCallback(
+ (accounts) => {
+ setSelectedRows(accounts);
+ },
+ [setSelectedRows],
+ );
+
return (
-
-
-
+
+
-
-
+
+
+
- }
- confirmButtonText={}
- icon='trash'
- intent={Intent.DANGER}
- isOpen={deleteExpenseState}
- onCancel={handleCancelAccountDelete}
- onConfirm={handleConfirmAccountDelete}
- >
-
- Are you sure you want to move filename to Trash? You will be
- able to restore it later, but it will become private to you.
-
-
+
+
+
+
+ }
+ confirmButtonText={}
+ icon="trash"
+ intent={Intent.DANGER}
+ isOpen={deleteExpense}
+ onCancel={handleCancelExpenseDelete}
+ onConfirm={handleConfirmExpenseDelete}
+ >
+
+
+
+
+
+ {/* }
+ confirmButtonText={
+
+ }
+ icon="trash"
+ intent={Intent.DANGER}
+ isOpen={bulkDelete}
+ onCancel={handleCancelBulkDelete}
+ onConfirm={handleConfirmBulkDelete}
+ >
+
+
+
+ */}
+
);
}
-export default connector(ExpensesList);
+export default compose(
+ withDashboardActions,
+ withExpensesActions,
+ withViewsActions,
+)(ExpensesList);
diff --git a/client/src/containers/Expenses/withExpenseDetail.js b/client/src/containers/Expenses/withExpenseDetail.js
new file mode 100644
index 000000000..d18648d00
--- /dev/null
+++ b/client/src/containers/Expenses/withExpenseDetail.js
@@ -0,0 +1,11 @@
+import { connect } from 'react-redux';
+import { getExpenseById } from 'store/expenses/expenses.reducer';
+
+const mapStateToProps = (state, props) => {
+
+ return {
+ expenseDetail: getExpenseById(state, props.expenseId),
+ };
+};
+
+export default connect(mapStateToProps);
diff --git a/client/src/containers/Expenses/withExpenses.js b/client/src/containers/Expenses/withExpenses.js
new file mode 100644
index 000000000..2665a66a7
--- /dev/null
+++ b/client/src/containers/Expenses/withExpenses.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import { getResourceViews } from 'store/customViews/customViews.selectors';
+import { getExpensesItems } from 'store/expenses/expenses.selectors';
+
+export default (mapState) => {
+ const mapStateToProps = (state, props) => {
+ const mapped = {
+ expenses: getExpensesItems(state, state.expenses.currentViewId),
+ expensesViews: getResourceViews(state, 'expenses'),
+ expensesItems: state.expenses.items,
+ expensesTableQuery: state.expenses.tableQuery,
+ expensesLoading: state.expenses.loading,
+ };
+ return mapState ? mapState(mapped, state, props) : mapped;
+ };
+
+ return connect(mapStateToProps);
+};
diff --git a/client/src/containers/Expenses/withExpensesActions.js b/client/src/containers/Expenses/withExpensesActions.js
new file mode 100644
index 000000000..64118f984
--- /dev/null
+++ b/client/src/containers/Expenses/withExpensesActions.js
@@ -0,0 +1,37 @@
+import { connect } from 'react-redux';
+import {
+ submitExpense,
+ fetchExpense,
+ editExpense,
+ deleteExpense,
+ deleteBulkExpenses,
+ publishExpense,
+ fetchExpensesTable,
+} from 'store/expenses/expenses.actions';
+import t from 'store/types';
+
+export const mapDispatchToProps = (dispatch) => ({
+ requestSubmitExpense: (form) => dispatch(submitExpense({ form })),
+ requestFetchExpense: (id) => dispatch(fetchExpense({ id })),
+ requestEditExpense: (id, form) => dispatch(editExpense({ id, form })),
+
+ requestDeleteExpense: (id) => dispatch(deleteExpense({ id })),
+ requestFetchExpensesTable: (query = {}) =>
+ dispatch(fetchExpensesTable({ query: { ...query } })),
+ requestPublishExpense: (id) => dispatch(publishExpense({ id })),
+ requestDeleteBulkExpenses: (ids) => dispatch(deleteBulkExpenses({ ids })),
+
+ changeCurrentView: (id) =>
+ dispatch({
+ type: t.EXPENSES_SET_CURRENT_VIEW,
+ currentViewId: parseInt(id, 10),
+ }),
+
+ addExpensesTableQueries: (queries) =>
+ dispatch({
+ type: t.EXPENSES_TABLE_QUERIES_ADD,
+ queries,
+ }),
+});
+
+export default connect(null, mapDispatchToProps);
diff --git a/client/src/lang/en/index.js b/client/src/lang/en/index.js
index 7dcd00767..ad9c706e9 100644
--- a/client/src/lang/en/index.js
+++ b/client/src/lang/en/index.js
@@ -203,7 +203,8 @@ export default {
'The journal has been successfully deleted',
the_manual_journal_id_has_been_published:
'The manual journal id has been published',
-
+ the_journals_has_been_successfully_deleted:
+ 'The journals has been successfully deleted ',
credit: 'Credit',
debit: 'Debit',
once_delete_this_item_you_will_able_to_restore_it: `Once you delete this item, you won\'t be able to restore the item later. Are you sure you want to delete ?
If you're not sure, you can inactivate it instead.`,
@@ -226,8 +227,6 @@ export default {
table: 'Table',
nucleus: 'Nucleus',
logout: 'Logout',
- the_expense_has_been_successfully_created:
- 'The expense has been successfully created.',
select_payment_account: 'Select Payment Account',
select_expense_account: 'Select Expense Account',
and: 'And',
@@ -357,6 +356,10 @@ export default {
'There is exchange rate in this date with the same currency.',
the_exchange_rates_has_been_successfully_deleted:
'The exchange rates has been successfully deleted',
+
+ once_delete_this_expense_you_will_able_to_restore_it: `Once you delete this expense, you won\'t be able to restore it later. Are you sure you want to delete this expense?`,
+
+
january: 'January',
february: 'February',
march: 'March',
@@ -417,4 +420,31 @@ export default {
quick_new: 'Quick new',
help: 'Help',
organization_id: 'Orgnization ID',
+ beneficiary: 'Beneficiary',
+ payment_account: 'Payment Account',
+ payment_date: 'Payment Date',
+ ref_no: 'Ref No.',
+ payment_account_: 'Payment account',
+ expense_category: 'Expense Category',
+ total_currency: 'Total ({currency})',
+ amount_currency: 'Amount({currency})',
+ publish_expense: 'Publish Expense',
+ edit_expense: 'Edit Expense',
+ delete_expense: 'Delete Expense',
+ new_expense: 'New Expense',
+ full_amount: 'Full Amount',
+ payment_date_: 'Payment date',
+
+ the_expense_has_been_successfully_created:
+ 'The expense #{number} has been successfully created.',
+ the_expense_has_been_successfully_edited:
+ 'The expense #{number} has been successfully edited.',
+ the_expense_has_been_successfully_deleted:
+ 'The expense has been successfully deleted',
+ the_expenses_has_been_successfully_deleted:
+ 'The expenses has been successfully deleted',
+ the_expense_id_has_been_published: 'The expense id has been published',
+
+ select_beneficiary_account: 'Select Beneficiary Account',
+ total_amount_equals_zero: 'Total amount equals zero',
};
diff --git a/client/src/routes/dashboard.js b/client/src/routes/dashboard.js
index 2d2f0f9c3..a51887637 100644
--- a/client/src/routes/dashboard.js
+++ b/client/src/routes/dashboard.js
@@ -141,4 +141,28 @@ export default [
}),
breadcrumb: 'Exchange Rates',
},
+
+ // Expenses
+ {
+ path: `/expenses/new`, // expenses/
+ component: LazyLoader({
+ loader: () => import('containers/Expenses/Expenses'),
+ }),
+ breadcrumb: 'Expenses',
+
+ },
+ {
+ path: `/expenses/:id/edit`,
+ component: LazyLoader({
+ loader: () => import('containers/Expenses/Expenses'),
+ }),
+ breadcrumb: 'Edit',
+ },
+ {
+ path: `/expenses-list`,
+ component: LazyLoader({
+ loader: () => import('containers/Expenses/ExpensesList'),
+ }),
+ breadcrumb: 'Expenses List',
+ },
];
diff --git a/client/src/store/expenses/expenses.actions.js b/client/src/store/expenses/expenses.actions.js
index e6ee5f7ae..9bc992590 100644
--- a/client/src/store/expenses/expenses.actions.js
+++ b/client/src/store/expenses/expenses.actions.js
@@ -1,41 +1,144 @@
-import ApiService from "services/ApiService";
+import ApiService from 'services/ApiService';
import t from 'store/types';
-export const fetchExpensesList = ({ query }) => {
- return (dispatch) => new Promise((resolve, reject) => {
- ApiService.get('expenses').then((response) => {
+export const fetchExpensesTable = ({ query } = {}) => {
+ return (dispatch, getState) =>
+ new Promise((resolve, reject) => {
+ const pageQuery = getState().expenses.tableQuery;
dispatch({
- type: t.EXPENSES_LIST_SET,
- expenses: response.data.expenses,
+ type: t.SET_DASHBOARD_REQUEST_LOADING,
});
- }).catch(error => { reject(error); });
- });
+ dispatch({
+ type: t.EXPENSES_TABLE_LOADING,
+ loading: true,
+ });
+ ApiService.get('expenses', {
+ params: { ...pageQuery, ...query },
+ })
+ .then((response) => {
+ dispatch({
+ type: t.EXPENSES_PAGE_SET,
+ expenses: response.data.expenses.results,
+ customViewId: response.data.customViewId || -1,
+ });
+ dispatch({
+ type: t.EXPENSES_ITEMS_SET,
+ expenses: response.data.expenses.results,
+ });
+ dispatch({
+ type: t.EXPENSES_TABLE_LOADING,
+ loading: false,
+ });
+ dispatch({
+ type: t.SET_DASHBOARD_REQUEST_COMPLETED,
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ });
};
export const fetchExpense = ({ id }) => {
- return (dispatch) => new Promise((resolve, reject) => {
- ApiService.get(`expenses/${id}`).then((response) => {
- dispatch({
- type: t.EXPENSE_SET,
- expense: response.data.expense,
- });
- }).catch(error => { reject(error); });
- });
-};
-
-export const submitExpense = ({ form }) => {
- return (dispatch) => ApiService.post('expenses', { ...form });
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ ApiService.get(`expenses/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.EXPENSE_SET,
+ payload: {
+ id,
+ expense: response.data.expense,
+ },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ });
};
export const editExpense = ({ form, id }) => {
- return (dispatch) => ApiService.post(`expensed/${id}`, form);
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ ApiService.post(`expenses/${id}`, form)
+ .then((response) => {
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+
+ reject(data?.errors);
+ });
+ });
+};
+
+export const submitExpense = ({ form }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ ApiService.post('expenses', form)
+ .then((response) => {
+ resolve(response);
+ })
+ .catch((error) => {
+ const { response } = error;
+ const { data } = response;
+
+ reject(data?.errors);
+ });
+ });
};
export const deleteExpense = ({ id }) => {
- return (dispatch) => ApiService.delete(`expenses/${id}`);
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ ApiService.delete(`expenses/${id}`)
+ .then((response) => {
+ dispatch({
+ type: t.EXPENSE_DELETE,
+ payload: { id },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ reject(error.response.data.errors || []);
+ });
+ });
+};
+
+export const deleteBulkExpenses = ({ ids }) => {
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ ApiService.delete('expenses/bulk', { params: { ids } })
+ .then((response) => {
+ dispatch({
+ type: t.EXPENSES_BULK_DELETE,
+ payload: { ids },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ reject(error.response.data.errors || []);
+ });
+ });
};
export const publishExpense = ({ id }) => {
- return (dispatch) => ApiService.post(`expenses/${id}/publish`);
+ return (dispatch) =>
+ new Promise((resolve, reject) => {
+ ApiService.post(`expenses/${id}/publish`)
+ .then((response) => {
+ dispatch({
+ type: t.EXPENSE_PUBLISH,
+ payload: { id },
+ });
+ resolve(response);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ });
};
-
diff --git a/client/src/store/expenses/expenses.reducer.js b/client/src/store/expenses/expenses.reducer.js
index f4efdf8e4..0b2729111 100644
--- a/client/src/store/expenses/expenses.reducer.js
+++ b/client/src/store/expenses/expenses.reducer.js
@@ -1,21 +1,92 @@
import { createReducer } from '@reduxjs/toolkit';
+import { createTableQueryReducers } from 'store/queryReducers';
+
import t from 'store/types';
+import { omit } from 'lodash';
const initialState = {
- list: [],
- detailsById: {},
+ items: {},
+ views: {},
+ loading: false,
+ currentViewId: -1,
};
-export default createReducer(initialState, {
- [t.EXPENSES_LIST_SET]: (state, action) => {
- state.list = action.expenses;
+const defaultExpense = {
+ categories: [],
+};
+
+const reducer = createReducer(initialState, {
+ [t.EXPENSE_SET]: (state, action) => {
+ const { id, expense } = action.payload;
+ state.items[id] = { ...defaultExpense, ...expense };
},
- [t.EXPENSE_SET]: (state, action) => {
- state.detailsById[action.expense.id] = action.expense;
+ [t.EXPENSE_PUBLISH]: (state, action) => {
+ const { id } = action.payload;
+ const item = state.items[id] || {};
+
+ state.items[id] = { ...item, status: 1 };
+ },
+
+ [t.EXPENSES_ITEMS_SET]: (state, action) => {
+ const _expenses = {};
+
+ action.expenses.forEach((expense) => {
+ _expenses[expense.id] = {
+ ...defaultExpense,
+ ...expense,
+ };
+ });
+ state.items = {
+ ...state.items,
+ ..._expenses,
+ };
+ },
+
+ [t.EXPENSES_PAGE_SET]: (state, action) => {
+ const viewId = action.customViewId || -1;
+ const view = state.views[viewId] || {};
+
+ state.views[viewId] = {
+ ...view,
+ ids: action.expenses.map((i) => i.id),
+ };
+ },
+
+ [t.EXPENSES_TABLE_LOADING]: (state, action) => {
+ state.loading = action.loading;
+ },
+
+ [t.EXPENSES_SET_CURRENT_VIEW]: (state, action) => {
+ state.currentViewId = action.currentViewId;
+ },
+
+ [t.EXPENSE_DELETE]: (state, action) => {
+ const { id } = action.payload;
+ // state.items = omit(state.items, [id]);
+
+ if (typeof state.items[id] !== 'undefined') {
+ delete state.items[id];
+ }
+ },
+
+ [t.EXPENSES_BULK_DELETE]: (state, action) => {
+ const { ids } = action.payload;
+ const items = { ...state.items };
+
+ ids.forEach((id) => {
+ if (typeof items[id] !== 'undefined') {
+ delete items[id];
+ }
+ });
+ state.items = items;
},
});
+export default createTableQueryReducers('expenses', reducer);
+
export const getExpenseById = (state, id) => {
- return state.expenses.detailsById[id];
-};
\ No newline at end of file
+ // debugger;
+ // state.items = omit(state.items, [id]);
+ return state.expenses.items[id];
+};
diff --git a/client/src/store/expenses/expenses.selectors.js b/client/src/store/expenses/expenses.selectors.js
new file mode 100644
index 000000000..c48fd06f7
--- /dev/null
+++ b/client/src/store/expenses/expenses.selectors.js
@@ -0,0 +1,10 @@
+import { pickItemsFromIds } from 'store/selectors';
+
+export const getExpensesItems = (state, viewId) => {
+ const accountsView = state.expenses.views[viewId || -1];
+ const accountsItems = state.expenses.items;
+
+ return typeof accountsView === 'object'
+ ? pickItemsFromIds(accountsItems, accountsView.ids) || []
+ : [];
+};
diff --git a/client/src/store/expenses/expenses.types.js b/client/src/store/expenses/expenses.types.js
index 610157207..445bfb39b 100644
--- a/client/src/store/expenses/expenses.types.js
+++ b/client/src/store/expenses/expenses.types.js
@@ -1,5 +1,12 @@
-
export default {
EXPENSES_LIST_SET: 'EXPENSES_LIST_SET',
EXPENSE_SET: 'EXPENSE_SET',
-};
\ No newline at end of file
+ EXPENSE_DELETE: 'EXPENSE_DELETE',
+ EXPENSES_BULK_DELETE: 'EXPENSES_BULK_DELETE',
+ EXPENSES_SET_CURRENT_VIEW: 'EXPENSES_SET_CURRENT_VIEW',
+ EXPENSES_TABLE_QUERIES_ADD:'EXPENSES_TABLE_QUERIES_ADD',
+ EXPENSE_PUBLISH: 'EXPENSE_PUBLISH',
+ EXPENSES_TABLE_LOADING: 'EXPENSES_TABLE_LOADING',
+ EXPENSES_PAGE_SET: 'EXPENSES_PAGE_SET',
+ EXPENSES_ITEMS_SET: 'EXPENSES_ITEMS_SET',
+};
diff --git a/client/src/style/pages/expense-form.scss b/client/src/style/pages/expense-form.scss
index ab101d880..1c5021350 100644
--- a/client/src/style/pages/expense-form.scss
+++ b/client/src/style/pages/expense-form.scss
@@ -1,17 +1,239 @@
+.dashboard__insider--expense-form {
+ padding-bottom: 80px;
+ display: flex;
+ flex-direction: column;
-
-.dashboard__insider--expense-form{
- padding: 40px 20px;
-
- .#{$ns}-form-group{
- margin-bottom: 22px;
-
- .#{$ns}-label{
- min-width: 130px;
+ &__header {
+ padding: 25px 27px 20px;
+ background: #fbfbfb;
+ width: 100%;
+ .bp3-form-group {
+ .bp3-label {
+ margin-bottom: 15px;
+ margin-right: 15px;
+ font-weight: 500;
+ font-size: 13px;
+ color: #444;
+ }
+ .bp3-form-content {
+ .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) {
+ min-width: 300px;
+ min-height: 32px;
+ background: #fff;
+ box-shadow: 0 0 0 transparent;
+ border: 1px solid #ced4da;
+ }
+ }
+ .bp3-input-group {
+ display: block;
+ position: relative;
+ width: 300px;
+ }
+ &.form-group--ref_no {
+ .bp3-input-group .bp3-input {
+ position: relative;
+ width: 180px;
+ }
+ }
}
- .#{$ns}-form-content{
- width: 300px;
+ .form-group--payment_account {
+ .bp3-form-group {
+ display: flex;
+ flex-direction: column;
+ margin: 0 0 15px;
+ }
+ .bp3-form-content {
+ .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) {
+ min-width: 380px;
+ min-height: 32px;
+ background: #fff;
+ box-shadow: 0 0 0 transparent;
+ border: 1px solid #ced4da;
+ }
+ }
+ }
+ .form-group--currency {
+ .bp3-form-group {
+ display: flex;
+ flex-direction: column;
+ margin: 0 0 15px;
+ }
+ .bp3-form-content {
+ .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) {
+ min-width: 180px;
+ min-height: 32px;
+ background: #fff;
+ box-shadow: 0 0 0 transparent;
+ border: 1px solid #ced4da;
+ }
+ }
+ }
+ }
+
+ &__table {
+ padding: 15px 25px 0;
+
+ .bp3-form-group {
+ margin-bottom: 0;
+ }
+ .table {
+ border: 1px dotted rgb(195, 195, 195);
+ border-bottom: transparent;
+ border-left: transparent;
+
+ .th,
+ .td {
+ border-left: 1px dotted rgb(195, 195, 195);
+
+ &.index {
+ span {
+ width: 100%;
+ font-weight: 500;
+ }
+ }
+ }
+
+ .thead {
+ .tr .th {
+ padding: 10px 10px;
+ background-color: #f2f5fa;
+ font-size: 14px;
+ font-weight: 500;
+ color: #333;
+ }
+ }
+
+ .tbody {
+ .tr .td {
+ padding: 7px;
+ border-bottom: 1px dotted rgb(195, 195, 195);
+ min-height: 46px;
+
+ &.index {
+ background-color: #f2f5fa;
+ text-align: center;
+
+ > span {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
+ }
+ .tr {
+ .bp3-input,
+ .form-group--select-list .bp3-button {
+ border-color: #e5e5e5;
+ border-radius: 3px;
+ padding-left: 8px;
+ padding-right: 8px;
+ }
+
+ .form-group--select-list {
+ &.bp3-intent-danger {
+ .bp3-button:not(.bp3-minimal) {
+ border-color: #efa8a8;
+ }
+ }
+ }
+
+ &:last-of-type {
+ .td {
+ border-bottom: transparent;
+
+ .bp3-button,
+ .bp3-input-group {
+ display: none;
+ }
+ }
+ }
+
+ .td.actions {
+ .bp3-button {
+ background-color: transparent;
+ color: #e68f8e;
+
+ &:hover {
+ color: #c23030;
+ }
+ }
+ }
+
+ &.row--total {
+ .account.td,
+ .debit.td,
+ .credit.td {
+ > span {
+ padding-top: 6px;
+ }
+ }
+ .debit.td,
+ .credit.td {
+ > span {
+ font-weight: 600;
+ color: #444;
+ }
+ }
+ }
+ }
+ }
+ .th {
+ color: #444;
+ font-weight: 600;
+ border-bottom: 1px dotted #666;
+ }
+
+ .td {
+ border-bottom: 1px dotted #999;
+ }
+
+ .actions.td {
+ .bp3-button {
+ background: transparent;
+ margin: 0;
+ }
+ }
+ }
+ }
+ .bp3-button {
+ &.button--clear-lines {
+ background-color: #fcefef;
+ }
+ }
+ .button--clear-lines,
+ .button--new-line {
+ padding-left: 14px;
+ padding-right: 14px;
+ }
+ .dropzone-container {
+ margin-top: 0;
+ align-self: flex-end;
+ }
+ .dropzone {
+ width: 300px;
+ height: 75px;
+
+ margin-right: 20px;
+ }
+
+ .form-group--description {
+ padding: 25px 27px 20px;
+ width: 100%;
+
+ .bp3-label {
+ margin-bottom: 15px;
+ margin-right: 15px;
+ font-weight: 500;
+ font-size: 13px;
+ color: #444;
+ }
+ .bp3-form-content {
+ // width: 280px;
+ textarea {
+ width: 300px;
+ height: 75px;
+ margin-right: 20px;
+ }
}
}
}