chrone: sperate client and server to different repos.

This commit is contained in:
a.bouhuolia
2021-09-21 17:13:53 +02:00
parent e011b2a82b
commit 18df5530c7
10015 changed files with 17686 additions and 97524 deletions

View File

@@ -0,0 +1,140 @@
import React from 'react';
import Icon from 'components/Icon';
import {
Button,
NavbarGroup,
Classes,
NavbarDivider,
Intent,
Alignment,
} from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import {
AdvancedFilterPopover,
DashboardFilterButton,
FormattedMessage as T,
} from 'components';
import { useRefreshJournals } from 'hooks/query/manualJournals';
import { useManualJournalsContext } from './ManualJournalsListProvider';
import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar';
import withDialogActions from 'containers/Dialog/withDialogActions';
import withManualJournalsActions from './withManualJournalsActions';
import withManualJournals from './withManualJournals';
import { If, DashboardActionViewsList } from 'components';
import { compose } from 'utils';
/**
* Manual journal actions bar.
*/
function ManualJournalActionsBar({
// #withManualJournalsActions
setManualJournalsTableState,
// #withManualJournals
manualJournalsFilterConditions,
}) {
// History context.
const history = useHistory();
// Manual journals context.
const { journalsViews, fields } = useManualJournalsContext();
// Manual journals refresh action.
const { refresh } = useRefreshJournals();
// Handle click a new manual journal.
const onClickNewManualJournal = () => {
history.push('/make-journal-entry');
};
// Handle delete button click.
const handleBulkDelete = () => {};
// Handle tab change.
const handleTabChange = (view) => {
setManualJournalsTableState({ viewSlug: view ? view.slig : null });
};
// Handle click a refresh Journals
const handleRefreshBtnClick = () => {
refresh();
};
return (
<DashboardActionsBar>
<NavbarGroup>
<DashboardActionViewsList
resourceName={'manual-journals'}
allMenuItem={true}
views={journalsViews}
onChange={handleTabChange}
/>
<NavbarDivider />
<Button
className={Classes.MINIMAL}
icon={<Icon icon="plus" />}
text={<T id={'journal_entry'} />}
onClick={onClickNewManualJournal}
/>
<AdvancedFilterPopover
advancedFilterProps={{
conditions: manualJournalsFilterConditions,
defaultFieldKey: 'journal_number',
fields,
onFilterChange: (filterConditions) => {
setManualJournalsTableState({ filterRoles: filterConditions });
},
}}
>
<DashboardFilterButton
conditionsCount={manualJournalsFilterConditions.length}
/>
</AdvancedFilterPopover>
<If condition={false}>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="trash-16" iconSize={16} />}
text={<T id={'delete'} />}
intent={Intent.DANGER}
onClick={handleBulkDelete}
/>
</If>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="print-16" iconSize={16} />}
text={<T id={'print'} />}
/>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />}
text={<T id={'import'} />}
/>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="file-export-16" iconSize={16} />}
text={<T id={'export'} />}
/>
</NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="refresh-16" iconSize={14} />}
onClick={handleRefreshBtnClick}
/>
</NavbarGroup>
</DashboardActionsBar>
);
}
export default compose(
withDialogActions,
withManualJournalsActions,
withManualJournals(({ manualJournalsTableState }) => ({
manualJournalsFilterConditions: manualJournalsTableState.filterRoles,
})),
)(ManualJournalActionsBar);

View File

@@ -0,0 +1,15 @@
import React from 'react';
import JournalDeleteAlert from 'containers/Alerts/ManualJournals/JournalDeleteAlert';
import JournalPublishAlert from 'containers/Alerts/ManualJournals/JournalPublishAlert';
/**
* Manual journals alerts.
*/
export default function ManualJournalsAlerts() {
return (
<div>
<JournalDeleteAlert name={'journal-delete'} />
<JournalPublishAlert name={'journal-publish'} />
</div>
)
}

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import { DataTable, DashboardContentTable } from 'components';
import { TABLES } from 'common/tables';
import ManualJournalsEmptyStatus from './ManualJournalsEmptyStatus';
import TableSkeletonRows from 'components/Datatable/TableSkeletonRows';
import TableSkeletonHeader from 'components/Datatable/TableHeaderSkeleton';
import { ActionsMenu } from './components';
import withManualJournals from './withManualJournals';
import withManualJournalsActions from './withManualJournalsActions';
import withAlertsActions from 'containers/Alert/withAlertActions';
import withDrawerActions from 'containers/Drawer/withDrawerActions';
import { useManualJournalsContext } from './ManualJournalsListProvider';
import { useMemorizedColumnsWidths } from 'hooks';
import { useManualJournalsColumns } from './utils';
import { compose } from 'utils';
/**
* Manual journals data-table.
*/
function ManualJournalsDataTable({
// #withManualJournalsActions
setManualJournalsTableState,
// #withAlertsActions
openAlert,
// #withDrawerActions
openDrawer,
// #withManualJournals
manualJournalsTableState,
// #ownProps
onSelectedRowsChange,
}) {
// Manual journals context.
const {
manualJournals,
pagination,
isManualJournalsLoading,
isManualJournalsFetching,
isEmptyStatus,
} = useManualJournalsContext();
const history = useHistory();
// Manual journals columns.
const columns = useManualJournalsColumns();
// Handles the journal publish action.
const handlePublishJournal = ({ id }) => {
openAlert('journal-publish', { manualJournalId: id });
};
// Handle the journal edit action.
const handleEditJournal = ({ id }) => {
history.push(`/manual-journals/${id}/edit`);
};
// Handle the journal delete action.
const handleDeleteJournal = ({ id }) => {
openAlert('journal-delete', { manualJournalId: id });
};
// Handle view detail journal.
const handleViewDetailJournal = ({ id }) => {
openDrawer('journal-drawer', {
manualJournalId: id,
});
};
// Handle cell click.
const handleCellClick = (cell, event) => {
openDrawer('journal-drawer', { manualJournalId: cell.row.original.id });
};
// Local storage memorizing columns widths.
const [initialColumnsWidths, , handleColumnResizing] =
useMemorizedColumnsWidths(TABLES.MANUAL_JOURNALS);
// Handle fetch data once the page index, size or sort by of the table change.
const handleFetchData = React.useCallback(
({ pageSize, pageIndex, sortBy }) => {
setManualJournalsTableState({
pageIndex,
pageSize,
sortBy,
});
},
[setManualJournalsTableState],
);
// Display manual journal empty status instead of the table.
if (isEmptyStatus) {
return <ManualJournalsEmptyStatus />;
}
return (
<DashboardContentTable>
<DataTable
noInitialFetch={true}
columns={columns}
data={manualJournals}
manualSortBy={true}
selectionColumn={true}
expandable={true}
sticky={true}
loading={isManualJournalsLoading}
headerLoading={isManualJournalsLoading}
progressBarLoading={isManualJournalsFetching}
pagesCount={pagination.pagesCount}
pagination={true}
autoResetSortBy={false}
autoResetPage={false}
TableLoadingRenderer={TableSkeletonRows}
TableHeaderSkeletonRenderer={TableSkeletonHeader}
ContextMenu={ActionsMenu}
onFetchData={handleFetchData}
onCellClick={handleCellClick}
initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing}
payload={{
onDelete: handleDeleteJournal,
onPublish: handlePublishJournal,
onEdit: handleEditJournal,
onViewDetails: handleViewDetailJournal,
}}
/>
</DashboardContentTable>
);
}
export default compose(
withManualJournalsActions,
withManualJournals(({ manualJournalsTableState }) => ({
manualJournalsTableState,
})),
withAlertsActions,
withDrawerActions,
)(ManualJournalsDataTable);

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Button, Intent } from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import { EmptyStatus } from 'components';
import { FormattedMessage as T } from 'components';
export default function ManualJournalsEmptyStatus() {
const history = useHistory();
return (
<EmptyStatus
title={<T id={'manual_journals.empty_status.title'} />}
description={
<p>
<T id={'manual_journals.empty_status.description'} />
</p>
}
action={
<>
<Button
intent={Intent.PRIMARY}
large={true}
onClick={() => {
history.push('/make-journal-entry');
}}
>
<T id={'make_journal'} />
</Button>
<Button intent={Intent.NONE} large={true}>
<T id={'learn_more'} />
</Button>
</>
}
/>
);
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import 'style/pages/ManualJournal/List.scss';
import { DashboardContentTable, DashboardPageContent } from 'components';
import { ManualJournalsListProvider } from './ManualJournalsListProvider';
import ManualJournalsAlerts from './ManualJournalsAlerts';
import ManualJournalsViewTabs from './ManualJournalsViewTabs';
import ManualJournalsDataTable from './ManualJournalsDataTable';
import ManualJournalsActionsBar from './ManualJournalActionsBar';
import withManualJournals from './withManualJournals';
import { transformTableStateToQuery, compose } from 'utils';
/**
* Manual journals table.
*/
function ManualJournalsTable({
// #withManualJournals
journalsTableState,
journalsTableStateChanged,
}) {
return (
<ManualJournalsListProvider
query={transformTableStateToQuery(journalsTableState)}
tableStateChanged={journalsTableStateChanged}
>
<ManualJournalsActionsBar />
<DashboardPageContent>
<ManualJournalsViewTabs />
<ManualJournalsDataTable />
</DashboardPageContent>
<ManualJournalsAlerts />
</ManualJournalsListProvider>
);
}
export default compose(
withManualJournals(
({ manualJournalsTableState, manualJournalTableStateChanged }) => ({
journalsTableState: manualJournalsTableState,
journalsTableStateChanged: manualJournalTableStateChanged,
}),
),
)(ManualJournalsTable);

View File

@@ -0,0 +1,61 @@
import React, { createContext } from 'react';
import { isEmpty } from 'lodash';
import DashboardInsider from 'components/Dashboard/DashboardInsider';
import { useResourceViews, useResourceMeta, useJournals } from 'hooks/query';
import { getFieldsFromResourceMeta } from 'utils';
const ManualJournalsContext = createContext();
function ManualJournalsListProvider({ query, tableStateChanged, ...props }) {
// Fetches accounts resource views and fields.
const { data: journalsViews, isLoading: isViewsLoading } =
useResourceViews('manual_journals');
// Fetches the manual journals transactions with pagination meta.
const {
data: { manualJournals, pagination, filterMeta },
isLoading: isManualJournalsLoading,
isFetching: isManualJournalsFetching,
} = useJournals(query, { keepPreviousData: true });
// Fetch the accounts resource fields.
const {
data: resourceMeta,
isLoading: isResourceMetaLoading,
isFetching: isResourceMetaFetching,
} = useResourceMeta('manual_journals');
// Detarmines the datatable empty status.
const isEmptyStatus =
isEmpty(manualJournals) && !tableStateChanged && !isManualJournalsLoading;
// Global state.
const state = {
manualJournals,
pagination,
journalsViews,
resourceMeta,
fields: getFieldsFromResourceMeta(resourceMeta.fields),
isManualJournalsLoading,
isManualJournalsFetching,
isViewsLoading,
isEmptyStatus,
};
const isPageLoading =
isManualJournalsLoading || isViewsLoading || isResourceMetaLoading;
return (
<DashboardInsider loading={isPageLoading} name={'manual-journals'}>
<ManualJournalsContext.Provider value={state} {...props} />
</DashboardInsider>
);
}
const useManualJournalsContext = () => React.useContext(ManualJournalsContext);
export { ManualJournalsListProvider, useManualJournalsContext };

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Alignment, Navbar, NavbarGroup } from '@blueprintjs/core';
import { useParams } from 'react-router-dom';
import { pick } from 'lodash';
import { DashboardViewsTabs } from 'components';
import { useManualJournalsContext } from './ManualJournalsListProvider';
import withManualJournalsActions from './withManualJournalsActions';
import withDashboardActions from 'containers/Dashboard/withDashboardActions';
import withManualJournals from './withManualJournals';
import { compose } from 'utils';
/**
* Manual journal views tabs.
*/
function ManualJournalsViewTabs({
// #withManualJournalsActions
setManualJournalsTableState,
// #withManualJournals
journalsTableState
}) {
// Manual journals context.
const { journalsViews } = useManualJournalsContext();
const tabs = journalsViews.map((view) => ({
...pick(view, ['name', 'id']),
}));
const handleClickNewView = () => {};
// Handles the tab change.
const handleTabChange = (viewId) => {
setManualJournalsTableState({
customViewId: viewId || null,
});
};
return (
<Navbar className="navbar--dashboard-views">
<NavbarGroup align={Alignment.LEFT}>
<DashboardViewsTabs
resourceName={'manual-journals'}
currentViewId={journalsTableState.customViewId}
tabs={tabs}
onChange={handleTabChange}
onNewViewTabClick={handleClickNewView}
/>
</NavbarGroup>
</Navbar>
);
}
export default compose(
withManualJournalsActions,
withDashboardActions,
withManualJournals(({ manualJournalsTableState }) => ({
journalsTableState: manualJournalsTableState,
})),
)(ManualJournalsViewTabs);

View File

@@ -0,0 +1,174 @@
import React from 'react';
import {
Intent,
Classes,
Tooltip,
Position,
Tag,
Button,
MenuItem,
Menu,
MenuDivider,
Popover,
} from '@blueprintjs/core';
import intl from 'react-intl-universal';
import { FormattedMessage as T, Choose, Money, If, Icon } from 'components';
import { safeCallback } from 'utils';
/**
* Amount accessor.
*/
export const AmountAccessor = (r) => (
<Tooltip
content={
<AmountPopoverContent
journalEntries={r.entries}
currencyCode={r.currency_code}
/>
}
position={Position.RIGHT_TOP}
boundary={'viewport'}
>
{r.amount_formatted}
</Tooltip>
);
/**
* Amount popover content line.
*/
export const AmountPopoverContentLine = ({ journalEntry, currencyCode }) => {
const isCredit = !!journalEntry.credit;
const isDebit = !!journalEntry.debit;
const { account } = journalEntry;
return (
<Choose>
<Choose.When condition={isDebit}>
<div>
C. <Money amount={journalEntry.debit} currency={currencyCode} /> -{' '}
{account.name} <If condition={account.code}>({account.code})</If>
</div>
</Choose.When>
<Choose.When condition={isCredit}>
<div>
D. <Money amount={journalEntry.credit} currency={currencyCode} /> -{' '}
{account.name} <If condition={account.code}>({account.code})</If>
</div>
</Choose.When>
</Choose>
);
};
/**
* Amount popover content.
*/
export function AmountPopoverContent({ journalEntries, currencyCode }) {
const journalLinesProps = journalEntries.map((journalEntry) => ({
journalEntry,
accountId: journalEntry.account_id,
}));
return (
<div>
{journalLinesProps.map(({ journalEntry, accountId }) => (
<AmountPopoverContentLine
journalEntry={journalEntry}
accountId={accountId}
currencyCode={currencyCode}
/>
))}
</div>
);
}
/**
* Publish column accessor.
*/
export const StatusAccessor = (row) => {
return (
<Choose>
<Choose.When condition={!!row.is_published}>
<Tag minimal={true}>
<T id={'published'} />
</Tag>
</Choose.When>
<Choose.Otherwise>
<Tag minimal={true} intent={Intent.WARNING}>
<T id={'draft'} />
</Tag>
</Choose.Otherwise>
</Choose>
);
};
/**
* Note column accessor.
*/
export function NoteAccessor(row) {
return (
<If condition={row.description}>
<Tooltip
className={Classes.TOOLTIP_INDICATOR}
content={row.description}
position={Position.LEFT_TOP}
hoverOpenDelay={50}
>
<Icon icon={'file-alt'} iconSize={16} />
</Tooltip>
</If>
);
}
/**
* Table actions cell.
*/
export const ActionsCell = (props) => {
return (
<Popover
content={<ActionsMenu {...props} />}
position={Position.RIGHT_BOTTOM}
>
<Button icon={<Icon icon="more-h-16" iconSize={16} />} />
</Popover>
);
};
/**
* Actions menu of the table.
*/
export const ActionsMenu = ({
payload: { onPublish, onEdit, onDelete, onViewDetails },
row: { original },
}) => {
return (
<Menu>
<MenuItem
icon={<Icon icon="reader-18" />}
text={intl.get('view_details')}
onClick={safeCallback(onViewDetails, original)}
/>
<MenuDivider />
<If condition={!original.is_published}>
<MenuItem
icon={<Icon icon="arrow-to-top" />}
text={intl.get('publish_journal')}
onClick={safeCallback(onPublish, original)}
/>
</If>
<MenuItem
icon={<Icon icon="pen-18" />}
text={intl.get('edit_journal')}
onClick={safeCallback(onEdit, original)}
/>
<MenuItem
text={intl.get('delete_journal')}
icon={<Icon icon="trash-16" iconSize={16} />}
intent={Intent.DANGER}
onClick={safeCallback(onDelete, original)}
/>
</Menu>
);
};

View File

@@ -0,0 +1,74 @@
import React from 'react';
import intl from 'react-intl-universal';
import clsx from 'classnames';
import { CLASSES } from '../../../common/classes';
import { FormatDateCell } from '../../../components';
import { NoteAccessor, StatusAccessor } from './components';
/**
* Retrieve the manual journals columns.
*/
export const useManualJournalsColumns = () => {
return React.useMemo(
() => [
{
id: 'date',
Header: intl.get('date'),
accessor: 'date',
Cell: FormatDateCell,
width: 115,
className: 'date',
clickable: true,
},
{
id: 'amount',
Header: intl.get('amount'),
accessor: 'formatted_amount',
width: 115,
clickable: true,
align: 'right',
className: clsx(CLASSES.FONT_BOLD),
},
{
id: 'journal_number',
Header: intl.get('journal_no'),
accessor: (row) => `${row.journal_number}`,
className: 'journal_number',
width: 100,
clickable: true,
},
{
id: 'journal_type',
Header: intl.get('journal_type'),
accessor: 'journal_type',
width: 110,
clickable: true,
},
{
id: 'status',
Header: intl.get('publish'),
accessor: (row) => StatusAccessor(row),
width: 95,
clickable: true,
},
{
id: 'note',
Header: intl.get('note'),
accessor: NoteAccessor,
disableSortBy: true,
width: 85,
clickable: true,
},
{
id: 'created_at',
Header: intl.get('created_at'),
accessor: 'created_at',
Cell: FormatDateCell,
width: 125,
clickable: true,
},
],
[],
);
};

View File

@@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import {
getManualJournalsTableStateFactory,
manualJournalTableStateChangedFactory,
} from 'store/manualJournals/manualJournals.selectors';
export default (mapState) => {
const getJournalsTableQuery = getManualJournalsTableStateFactory();
const manualJournalTableStateChanged =
manualJournalTableStateChangedFactory();
const mapStateToProps = (state, props) => {
const mapped = {
manualJournalsTableState: getJournalsTableQuery(state, props),
manualJournalTableStateChanged: manualJournalTableStateChanged(
state,
props,
),
};
return mapState ? mapState(mapped, state, props) : mapped;
};
return connect(mapStateToProps);
};

View File

@@ -0,0 +1,11 @@
import { connect } from 'react-redux';
import {
setManualJournalsTableState,
} from 'store/manualJournals/manualJournals.actions';
const mapActionsToProps = (dispatch) => ({
setManualJournalsTableState: (queries) =>
dispatch(setManualJournalsTableState(queries)),
});
export default connect(null, mapActionsToProps);

View File

@@ -0,0 +1,41 @@
import * as Yup from 'yup';
import intl from 'react-intl-universal';
import { DATATYPES_LENGTH } from 'common/dataTypes';
const Schema = Yup.object().shape({
journal_number: Yup.string()
.required()
.min(1)
.max(DATATYPES_LENGTH.STRING)
.label(intl.get('journal_number_')),
journal_type: Yup.string()
.required()
.min(1)
.max(DATATYPES_LENGTH.STRING)
.label(intl.get('journal_type')),
date: Yup.date()
.required()
.label(intl.get('date')),
currency_code: Yup.string().max(3),
publish: Yup.boolean(),
reference: Yup.string().nullable().min(1).max(DATATYPES_LENGTH.STRING),
description: Yup.string().min(1).max(DATATYPES_LENGTH.STRING).nullable(),
entries: Yup.array().of(
Yup.object().shape({
credit: Yup.number().nullable(),
debit: Yup.number().nullable(),
account_id: Yup.number()
.nullable()
.when(['credit', 'debit'], {
is: (credit, debit) => credit || debit,
then: Yup.number().required(),
}),
contact_id: Yup.number().nullable(),
contact_type: Yup.string().nullable(),
note: Yup.string().max(DATATYPES_LENGTH.TEXT).nullable(),
}),
),
});
export const CreateJournalSchema = Schema;
export const EditJournalSchema = Schema;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { FastField } from 'formik';
import classNames from 'classnames';
import { CLASSES } from 'common/classes';
import MakeJournalEntriesTable from './MakeJournalEntriesTable';
import { entriesFieldShouldUpdate, defaultEntry, MIN_LINES_NUMBER } from './utils';
import { useMakeJournalFormContext } from './MakeJournalProvider';
/**
* Make journal entries field.
*/
export default function MakeJournalEntriesField() {
const { accounts, contacts } = useMakeJournalFormContext();
return (
<div className={classNames(CLASSES.PAGE_FORM_BODY)}>
<FastField
name={'entries'}
contacts={contacts}
accounts={accounts}
shouldUpdate={entriesFieldShouldUpdate}
>
{({
form: { values, setFieldValue },
field: { value },
meta: { error, touched },
}) => (
<MakeJournalEntriesTable
onChange={(entries) => {
setFieldValue('entries', entries);
}}
entries={value}
defaultEntry={defaultEntry}
initialLinesNumber={MIN_LINES_NUMBER}
error={error}
currencyCode={values.currency_code}
/>
)}
</FastField>
</div>
);
}

View File

@@ -0,0 +1,193 @@
import React from 'react';
import {
Intent,
Button,
ButtonGroup,
Popover,
PopoverInteractionKind,
Position,
Menu,
MenuItem,
} from '@blueprintjs/core';
import { FormattedMessage as T } from 'components';
import { useFormikContext } from 'formik';
import { CLASSES } from 'common/classes';
import classNames from 'classnames';
import { If, Icon } from 'components';
import { useMakeJournalFormContext } from './MakeJournalProvider';
import { useHistory } from 'react-router-dom';
/**
* Make Journal floating actions bar.
*/
export default function MakeJournalEntriesFooter() {
const history = useHistory();
// Formik context.
const { isSubmitting, submitForm } = useFormikContext();
// Make journal form context.
const {
manualJournalId,
setSubmitPayload,
manualJournalPublished = false,
} = useMakeJournalFormContext();
// Handle `submit & publish` button click.
const handleSubmitPublishBtnClick = (event) => {
setSubmitPayload({ redirect: true, publish: true });
submitForm();
};
// Handle `submit, publish & new` button click.
const handleSubmitPublishAndNewBtnClick = (event) => {
setSubmitPayload({ redirect: false, publish: true, resetForm: true });
submitForm();
};
// Handle `submit, publish & continue editing` button click.
const handleSubmitPublishContinueEditingBtnClick = (event) => {
setSubmitPayload({ redirect: false, publish: true });
submitForm();
};
// Handle `submit as draft` button click.
const handleSubmitDraftBtnClick = (event) => {
setSubmitPayload({ redirect: true, publish: false });
};
// Handle `submit as draft & new` button click.
const handleSubmitDraftAndNewBtnClick = (event) => {
setSubmitPayload({ redirect: false, publish: false, resetForm: true });
submitForm();
};
// Handles submit as draft & continue editing button click.
const handleSubmitDraftContinueEditingBtnClick = (event) => {
setSubmitPayload({ redirect: false, publish: false });
submitForm();
};
// Handle cancel button action click.
const handleCancelBtnClick = (event) => {
history.goBack();
};
const handleClearBtnClick = (event) => {};
return (
<div className={classNames(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
{/* ----------- Save And Publish ----------- */}
<If condition={!manualJournalId || !manualJournalPublished}>
<ButtonGroup>
<Button
disabled={isSubmitting}
intent={Intent.PRIMARY}
type="submit"
onClick={handleSubmitPublishBtnClick}
text={<T id={'save_publish'} />}
/>
<Popover
content={
<Menu>
<MenuItem
text={<T id={'publish_and_new'} />}
onClick={handleSubmitPublishAndNewBtnClick}
/>
<MenuItem
text={<T id={'publish_continue_editing'} />}
onClick={handleSubmitPublishContinueEditingBtnClick}
/>
</Menu>
}
minimal={true}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT}
>
<Button
disabled={isSubmitting}
intent={Intent.PRIMARY}
rightIcon={<Icon icon="arrow-drop-up-16" iconSize={20} />}
/>
</Popover>
</ButtonGroup>
{/* ----------- Save As Draft ----------- */}
<ButtonGroup>
<Button
disabled={isSubmitting}
className={'ml1'}
type="submit"
onClick={handleSubmitDraftBtnClick}
text={<T id={'save_as_draft'} />}
/>
<Popover
content={
<Menu>
<MenuItem
text={<T id={'save_and_new'} />}
onClick={handleSubmitDraftAndNewBtnClick}
/>
<MenuItem
text={<T id={'save_continue_editing'} />}
onClick={handleSubmitDraftContinueEditingBtnClick}
/>
</Menu>
}
minimal={true}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT}
>
<Button
disabled={isSubmitting}
rightIcon={<Icon icon="arrow-drop-up-16" iconSize={20} />}
/>
</Popover>
</ButtonGroup>
</If>
{/* ----------- Save and New ----------- */}
<If condition={manualJournalId && manualJournalPublished}>
<ButtonGroup>
<Button
disabled={isSubmitting}
intent={Intent.PRIMARY}
type="submit"
onClick={handleSubmitPublishBtnClick}
text={<T id={'save'} />}
/>
<Popover
content={
<Menu>
<MenuItem
text={<T id={'save_and_new'} />}
onClick={handleSubmitPublishAndNewBtnClick}
/>
</Menu>
}
minimal={true}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT}
>
<Button
disabled={isSubmitting}
intent={Intent.PRIMARY}
rightIcon={<Icon icon="arrow-drop-up-16" iconSize={20} />}
/>
</Popover>
</ButtonGroup>
</If>
{/* ----------- Clear & Reset----------- */}
<Button
className={'ml1'}
disabled={isSubmitting}
onClick={handleClearBtnClick}
text={manualJournalId ? <T id={'reset'} /> : <T id={'clear'} />}
/>
{/* ----------- Cancel ----------- */}
<Button
className={'ml1'}
onClick={handleCancelBtnClick}
text={<T id={'cancel'} />}
/>
</div>
);
}

View File

@@ -0,0 +1,193 @@
import React, { useMemo } from 'react';
import { Formik, Form } from 'formik';
import { Intent } from '@blueprintjs/core';
import intl from 'react-intl-universal';
import * as R from 'ramda';
import { defaultTo, isEmpty, omit } from 'lodash';
import classNames from 'classnames';
import { useHistory } from 'react-router-dom';
import { CLASSES } from 'common/classes';
import {
CreateJournalSchema,
EditJournalSchema,
} from './MakeJournalEntries.schema';
import MakeJournalEntriesHeader from './MakeJournalEntriesHeader';
import MakeJournalFormFloatingActions from './MakeJournalFormFloatingActions';
import MakeJournalEntriesField from './MakeJournalEntriesField';
import MakeJournalFormFooter from './MakeJournalFormFooter';
import MakeJournalFormDialogs from './MakeJournalFormDialogs';
import withSettings from 'containers/Settings/withSettings';
import withCurrentOrganization from 'containers/Organization/withCurrentOrganization';
import AppToaster from 'components/AppToaster';
import withMediaActions from 'containers/Media/withMediaActions';
import { compose, orderingLinesIndexes, transactionNumber } from 'utils';
import {
transformErrors,
transformToEditForm,
defaultManualJournal,
} from './utils';
import { useMakeJournalFormContext } from './MakeJournalProvider';
/**
* Journal entries form.
*/
function MakeJournalEntriesForm({
// #withSettings
journalNextNumber,
journalNumberPrefix,
journalAutoIncrement,
// #withCurrentOrganization
organization: { base_currency },
}) {
// Journal form context.
const {
createJournalMutate,
editJournalMutate,
isNewMode,
manualJournal,
submitPayload,
} = useMakeJournalFormContext();
const history = useHistory();
// New journal number.
const journalNumber = transactionNumber(
journalNumberPrefix,
journalNextNumber,
);
// Form initial values.
const initialValues = useMemo(
() => ({
...(!isEmpty(manualJournal)
? {
...transformToEditForm(manualJournal),
}
: {
...defaultManualJournal,
...(journalAutoIncrement && {
journal_number: defaultTo(journalNumber, ''),
}),
currency_code: base_currency,
}),
}),
[manualJournal, base_currency, journalNumber],
);
// Handle the form submiting.
const handleSubmit = (values, { setErrors, setSubmitting, resetForm }) => {
setSubmitting(true);
const entries = values.entries.filter(
(entry) => entry.debit || entry.credit,
);
const getTotal = (type = 'credit') => {
return entries.reduce((total, item) => {
return item[type] ? item[type] + total : total;
}, 0);
};
const totalCredit = getTotal('credit');
const totalDebit = getTotal('debit');
// Validate the total credit should be eqials total debit.
if (totalCredit !== totalDebit) {
AppToaster.show({
message: intl.get('should_total_of_credit_and_debit_be_equal'),
intent: Intent.DANGER,
});
setSubmitting(false);
return;
} else if (totalCredit === 0 || totalDebit === 0) {
AppToaster.show({
message: intl.get('amount_cannot_be_zero_or_empty'),
intent: Intent.DANGER,
});
setSubmitting(false);
return;
}
const form = {
...omit(values, ['journal_number', 'journal_number_manually']),
...(values.journal_number_manually && {
journal_number: values.journal_number,
}),
entries: R.compose(orderingLinesIndexes)(entries),
publish: submitPayload.publish,
};
// Handle the request error.
const handleError = ({
response: {
data: { errors },
},
}) => {
transformErrors(errors, { setErrors });
setSubmitting(false);
};
// Handle the request success.
const handleSuccess = (errors) => {
AppToaster.show({
message: intl.get(
isNewMode
? 'the_journal_has_been_created_successfully'
: 'the_journal_has_been_edited_successfully',
{ number: values.journal_number },
),
intent: Intent.SUCCESS,
});
setSubmitting(false);
if (submitPayload.redirect) {
history.push('/manual-journals');
}
if (submitPayload.resetForm) {
resetForm();
}
};
if (isNewMode) {
createJournalMutate(form).then(handleSuccess).catch(handleError);
} else {
editJournalMutate([manualJournal.id, form])
.then(handleSuccess)
.catch(handleError);
}
};
return (
<div
className={classNames(
CLASSES.PAGE_FORM,
CLASSES.PAGE_FORM_STRIP_STYLE,
CLASSES.PAGE_FORM_MAKE_JOURNAL,
)}
>
<Formik
initialValues={initialValues}
validationSchema={isNewMode ? CreateJournalSchema : EditJournalSchema}
onSubmit={handleSubmit}
>
<Form>
<MakeJournalEntriesHeader />
<MakeJournalEntriesField />
<MakeJournalFormFooter />
<MakeJournalFormFloatingActions />
{/* --------- Dialogs --------- */}
<MakeJournalFormDialogs />
</Form>
</Formik>
</div>
);
}
export default compose(
withMediaActions,
withSettings(({ manualJournalsSettings }) => ({
journalNextNumber: parseInt(manualJournalsSettings?.nextNumber, 10),
journalNumberPrefix: manualJournalsSettings?.numberPrefix,
journalAutoIncrement: manualJournalsSettings?.autoIncrement,
})),
withCurrentOrganization(),
)(MakeJournalEntriesForm);

View File

@@ -0,0 +1,30 @@
import React from 'react';
import classNames from 'classnames';
import { useFormikContext } from 'formik';
import { CLASSES } from 'common/classes';
import { FormattedMessage as T } from 'components';
import MakeJournalEntriesHeaderFields from './MakeJournalEntriesHeaderFields';
import { PageFormBigNumber } from 'components';
import { safeSumBy } from 'utils';
export default function MakeJournalEntriesHeader() {
const {
values: { entries, currency_code },
} = useFormikContext();
const totalCredit = safeSumBy(entries, 'credit');
const totalDebit = safeSumBy(entries, 'debit');
const total = Math.max(totalCredit, totalDebit);
return (
<div className={classNames(CLASSES.PAGE_FORM_HEADER)}>
<MakeJournalEntriesHeaderFields />
<PageFormBigNumber
label={<T id={'amount'} />}
amount={total}
currencyCode={currency_code}
/>
</div>
);
}

View File

@@ -0,0 +1,222 @@
import React from 'react';
import {
InputGroup,
FormGroup,
Position,
ControlGroup,
} from '@blueprintjs/core';
import { FastField, ErrorMessage } from 'formik';
import { DateInput } from '@blueprintjs/datetime';
import { FormattedMessage as T } from 'components';
import classNames from 'classnames';
import { CLASSES } from 'common/classes';
import {
momentFormatter,
compose,
inputIntent,
handleDateChange,
tansformDateValue,
} from 'utils';
import {
Hint,
FieldHint,
FieldRequiredHint,
Icon,
InputPrependButton,
CurrencySelectList,
} from 'components';
import withSettings from 'containers/Settings/withSettings';
import { useMakeJournalFormContext } from './MakeJournalProvider';
import withDialogActions from 'containers/Dialog/withDialogActions';
import {
currenciesFieldShouldUpdate,
useObserveJournalNoSettings,
} from './utils';
/**
* Make journal entries header.
*/
function MakeJournalEntriesHeader({
// #ownProps
onJournalNumberChanged,
// #withDialog
openDialog,
// #withSettings
journalAutoIncrement,
journalNextNumber,
journalNumberPrefix,
}) {
const { currencies } = useMakeJournalFormContext();
// Handle journal number change.
const handleJournalNumberChange = () => {
openDialog('journal-number-form', {});
};
// Handle journal number blur.
const handleJournalNoBlur = (form, field) => (event) => {
const newValue = event.target.value;
if (field.value !== newValue) {
openDialog('journal-number-form', {
initialFormValues: {
manualTransactionNo: newValue,
incrementMode: 'manual-transaction',
},
});
}
};
useObserveJournalNoSettings(journalNumberPrefix, journalNextNumber);
return (
<div className={classNames(CLASSES.PAGE_FORM_HEADER_FIELDS)}>
{/*------------ Posting date -----------*/}
<FastField name={'date'}>
{({ form, field: { value }, meta: { error, touched } }) => (
<FormGroup
label={<T id={'posting_date'} />}
labelInfo={<FieldRequiredHint />}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="date" />}
minimal={true}
inline={true}
className={classNames(CLASSES.FILL)}
>
<DateInput
{...momentFormatter('YYYY/MM/DD')}
onChange={handleDateChange((formattedDate) => {
form.setFieldValue('date', formattedDate);
})}
value={tansformDateValue(value)}
popoverProps={{
position: Position.BOTTOM,
minimal: true,
}}
inputProps={{
leftIcon: <Icon icon={'date-range'} />,
}}
/>
</FormGroup>
)}
</FastField>
{/*------------ Journal number -----------*/}
<FastField name={'journal_number'}>
{({ form, field, meta: { error, touched } }) => (
<FormGroup
label={<T id={'journal_no'} />}
labelInfo={
<>
<FieldRequiredHint />
<FieldHint />
</>
}
className={'form-group--journal-number'}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="journal_number" />}
fill={true}
inline={true}
>
<ControlGroup fill={true}>
<InputGroup
fill={true}
value={field.value}
asyncControl={true}
onBlur={handleJournalNoBlur(form, field)}
/>
<InputPrependButton
buttonProps={{
onClick: handleJournalNumberChange,
icon: <Icon icon={'settings-18'} />,
}}
tooltip={true}
tooltipProps={{
content: (
<T id={'setting_your_auto_generated_journal_number'} />
),
position: Position.BOTTOM_LEFT,
}}
/>
</ControlGroup>
</FormGroup>
)}
</FastField>
{/*------------ Reference -----------*/}
<FastField name={'reference'}>
{({ form, field, meta: { error, touched } }) => (
<FormGroup
label={<T id={'reference'} />}
labelInfo={
<Hint
content={<T id={'journal_reference_hint'} />}
position={Position.RIGHT}
/>
}
className={'form-group--reference'}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="reference" />}
fill={true}
inline={true}
>
<InputGroup fill={true} {...field} />
</FormGroup>
)}
</FastField>
{/*------------ Journal type -----------*/}
<FastField name={'journal_type'}>
{({ form, field, meta: { error, touched } }) => (
<FormGroup
label={<T id={'journal_type'} />}
className={classNames('form-group--account-type', CLASSES.FILL)}
inline={true}
>
<InputGroup
intent={inputIntent({ error, touched })}
fill={true}
{...field}
/>
</FormGroup>
)}
</FastField>
{/*------------ Currency -----------*/}
<FastField
name={'currency_code'}
currencies={currencies}
shouldUpdate={currenciesFieldShouldUpdate}
>
{({ form, field: { value }, meta: { error, touched } }) => (
<FormGroup
label={<T id={'currency'} />}
className={classNames('form-group--currency', CLASSES.FILL)}
inline={true}
>
<CurrencySelectList
currenciesList={currencies}
selectedCurrencyCode={value}
onCurrencySelected={(currencyItem) => {
form.setFieldValue('currency_code', currencyItem.currency_code);
}}
defaultSelectText={value}
disabled={true}
/>
</FormGroup>
)}
</FastField>
</div>
);
}
export default compose(
withDialogActions,
withSettings(({ manualJournalsSettings }) => ({
journalAutoIncrement: manualJournalsSettings?.autoIncrement,
journalNextNumber: manualJournalsSettings?.nextNumber,
journalNumberPrefix: manualJournalsSettings?.numberPrefix,
})),
)(MakeJournalEntriesHeader);

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import 'style/pages/ManualJournal/MakeJournal.scss';
import MakeJournalEntriesForm from './MakeJournalEntriesForm';
import { MakeJournalProvider } from './MakeJournalProvider';
/**
* Make journal entries page.
*/
export default function MakeJournalEntriesPage() {
const { id: journalId } = useParams();
return (
<MakeJournalProvider journalId={journalId}>
<MakeJournalEntriesForm />
</MakeJournalProvider>
);
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { DataTableEditable } from 'components';
import {
compose,
saveInvoke,
updateMinEntriesLines,
updateRemoveLineByIndex,
updateAutoAddNewLine,
updateTableCell,
} from 'utils';
import { useMakeJournalFormContext } from './MakeJournalProvider';
import { useJournalTableEntriesColumns } from './components';
import { updateAdjustEntries } from './utils';
/**
* Make journal entries table component.
*/
export default function MakeJournalEntriesTable({
// #ownPorps
onChange,
entries,
defaultEntry,
error,
initialLinesNumber = 4,
minLinesNumber = 4,
currencyCode,
}) {
const { accounts, contacts } = useMakeJournalFormContext();
// Memorized data table columns.
const columns = useJournalTableEntriesColumns();
// Handles update datatable data.
const handleUpdateData = (rowIndex, columnId, value) => {
const newRows = compose(
// Auto-adding new lines.
updateAutoAddNewLine(defaultEntry, ['account_id', 'credit', 'debit']),
// Update items entries total.
updateAdjustEntries(rowIndex, columnId, value),
// Update entry of the given row index and column id.
updateTableCell(rowIndex, columnId, value),
)(entries);
saveInvoke(onChange, newRows);
};
// Handle remove datatable row.
const handleRemoveRow = (rowIndex) => {
const newRows = compose(
// Ensure minimum lines count.
updateMinEntriesLines(minLinesNumber, defaultEntry),
// Remove the line by the given index.
updateRemoveLineByIndex(rowIndex),
)(entries);
saveInvoke(onChange, newRows);
};
return (
<DataTableEditable
columns={columns}
data={entries}
sticky={true}
totalRow={true}
footer={true}
payload={{
accounts,
errors: error,
updateData: handleUpdateData,
removeRow: handleRemoveRow,
contacts,
autoFocus: ['account_id', 0],
currencyCode,
}}
/>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { useFormikContext } from 'formik';
import JournalNumberDialog from 'containers/Dialogs/JournalNumberDialog';
/**
* Make journal form dialogs.
*/
export default function MakeJournalFormDialogs() {
const { setFieldValue } = useFormikContext();
// Update the form once the journal number form submit confirm.
const handleConfirm = ({ manually, incrementNumber }) => {
setFieldValue('journal_number', incrementNumber || '');
setFieldValue('journal_number_manually', manually);
};
return (
<>
<JournalNumberDialog
dialogName={'journal-number-form'}
onConfirm={handleConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,192 @@
import React from 'react';
import {
Intent,
Button,
ButtonGroup,
Popover,
PopoverInteractionKind,
Position,
Menu,
MenuItem,
} from '@blueprintjs/core';
import { useFormikContext } from 'formik';
import classNames from 'classnames';
import { FormattedMessage as T } from 'components';
import { CLASSES } from 'common/classes';
import { Icon, If } from 'components';
import { useHistory } from 'react-router-dom';
import { useMakeJournalFormContext } from './MakeJournalProvider';
/**
* Make Journal floating actions bar.
*/
export default function MakeJournalFloatingAction() {
const history = useHistory();
// Formik context.
const { submitForm, resetForm, isSubmitting } = useFormikContext();
// Make journal form context.
const { setSubmitPayload, manualJournal } = useMakeJournalFormContext();
// Handle submit & publish button click.
const handleSubmitPublishBtnClick = (event) => {
submitForm();
setSubmitPayload({ redirect: true, publish: true });
};
// Handle submit, publish & new button click.
const handleSubmitPublishAndNewBtnClick = (event) => {
submitForm();
setSubmitPayload({ redirect: false, publish: true, resetForm: true });
};
// Handle submit, publish & edit button click.
const handleSubmitPublishContinueEditingBtnClick = (event) => {
submitForm();
setSubmitPayload({ redirect: false, publish: true });
};
// Handle submit as draft button click.
const handleSubmitDraftBtnClick = (event) => {
submitForm();
setSubmitPayload({ redirect: true, publish: false });
};
// Handle submit as draft & new button click.
const handleSubmitDraftAndNewBtnClick = (event) => {
submitForm();
setSubmitPayload({ redirect: false, publish: false, resetForm: true });
};
// Handle submit as draft & continue editing button click.
const handleSubmitDraftContinueEditingBtnClick = (event) => {
submitForm();
setSubmitPayload({ redirect: false, publish: false });
};
// Handle cancel button click.
const handleCancelBtnClick = (event) => {
history.goBack();
};
// Handle clear button click.
const handleClearBtnClick = (event) => {
resetForm();
};
return (
<div className={classNames(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
{/* ----------- Save And Publish ----------- */}
<If condition={!manualJournal || !manualJournal?.is_published}>
<ButtonGroup>
<Button
loading={isSubmitting}
disabled={isSubmitting}
intent={Intent.PRIMARY}
onClick={handleSubmitPublishBtnClick}
text={<T id={'save_publish'} />}
/>
<Popover
content={
<Menu>
<MenuItem
text={<T id={'publish_and_new'} />}
onClick={handleSubmitPublishAndNewBtnClick}
/>
<MenuItem
text={<T id={'publish_continue_editing'} />}
onClick={handleSubmitPublishContinueEditingBtnClick}
/>
</Menu>
}
minimal={true}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT}
>
<Button
intent={Intent.PRIMARY}
rightIcon={<Icon icon="arrow-drop-up-16" iconSize={20} />}
disabled={isSubmitting}
/>
</Popover>
</ButtonGroup>
{/* ----------- Save As Draft ----------- */}
<ButtonGroup>
<Button
disabled={isSubmitting}
className={'ml1'}
onClick={handleSubmitDraftBtnClick}
text={<T id={'save_as_draft'} />}
/>
<Popover
content={
<Menu>
<MenuItem
text={<T id={'save_and_new'} />}
onClick={handleSubmitDraftAndNewBtnClick}
/>
<MenuItem
text={<T id={'save_continue_editing'} />}
onClick={handleSubmitDraftContinueEditingBtnClick}
/>
</Menu>
}
minimal={true}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT}
>
<Button
rightIcon={<Icon icon="arrow-drop-up-16" iconSize={20} />}
disabled={isSubmitting}
/>
</Popover>
</ButtonGroup>
</If>
{/* ----------- Save and New ----------- */}
<If condition={manualJournal && manualJournal?.is_published}>
<ButtonGroup>
<Button
loading={isSubmitting}
disabled={isSubmitting}
intent={Intent.PRIMARY}
onClick={handleSubmitPublishBtnClick}
text={<T id={'save'} />}
/>
<Popover
content={
<Menu>
<MenuItem
text={<T id={'save_and_new'} />}
onClick={handleSubmitPublishAndNewBtnClick}
/>
</Menu>
}
minimal={true}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT}
>
<Button
intent={Intent.PRIMARY}
rightIcon={<Icon icon="arrow-drop-up-16" iconSize={20} />}
disabled={isSubmitting}
/>
</Popover>
</ButtonGroup>
</If>
{/* ----------- Clear & Reset----------- */}
<Button
className={'ml1'}
disabled={isSubmitting}
onClick={handleClearBtnClick}
text={manualJournal ? <T id={'reset'} /> : <T id={'clear'} />}
/>
{/* ----------- Cancel ----------- */}
<Button
className={'ml1'}
onClick={handleCancelBtnClick}
text={<T id={'cancel'} />}
/>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { FastField } from 'formik';
import classNames from 'classnames';
import { CLASSES } from 'common/classes';
import { FormGroup, TextArea } from '@blueprintjs/core';
import { FormattedMessage as T } from 'components';
import { Postbox, ErrorMessage, Row, Col } from 'components';
import Dragzone from 'components/Dragzone';
import { inputIntent } from 'utils';
export default function MakeJournalFormFooter() {
return (
<div className={classNames(CLASSES.PAGE_FORM_FOOTER)}>
<Postbox title={<T id={'journal_details'} />} defaultOpen={false}>
<Row>
<Col md={8}>
<FastField name={'description'}>
{({ field, meta: { error, touched } }) => (
<FormGroup
label={<T id={'description'} />}
className={'form-group--description'}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="description" />}
fill={true}
>
<TextArea fill={true} {...field} />
</FormGroup>
)}
</FastField>
</Col>
<Col md={4}>
<Dragzone
initialFiles={[]}
// onDrop={handleDropFiles}
// onDeleteFile={handleDeleteFile}
hint={<T id={'attachments_maximum'} />}
/>
</Col>
</Row>
</Postbox>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import React, { createContext, useState } from 'react';
import DashboardInsider from 'components/Dashboard/DashboardInsider';
import {
useAccounts,
useAutoCompleteContacts,
useCurrencies,
useJournal,
useCreateJournal,
useEditJournal,
useSettings
} from 'hooks/query';
const MakeJournalFormContext = createContext();
/**
* Make journal form provider.
*/
function MakeJournalProvider({ journalId, ...props }) {
// Load the accounts list.
const { data: accounts, isLoading: isAccountsLoading } = useAccounts();
// Load the customers list.
const {
data: contacts,
isLoading: isContactsLoading,
} = useAutoCompleteContacts();
// Load the currencies list.
const { data: currencies, isLoading: isCurrenciesLoading } = useCurrencies();
// Load the details of the given manual journal.
const { data: manualJournal, isLoading: isJournalLoading } = useJournal(
journalId,
{
enabled: !!journalId,
},
);
// Create and edit journal mutations.
const { mutateAsync: createJournalMutate } = useCreateJournal();
const { mutateAsync: editJournalMutate } = useEditJournal();
// Loading the journal settings.
const { isLoading: isSettingsLoading } = useSettings();
// Submit form payload.
const [submitPayload, setSubmitPayload] = useState({});
const provider = {
accounts,
contacts,
currencies,
manualJournal,
createJournalMutate,
editJournalMutate,
isAccountsLoading,
isContactsLoading,
isCurrenciesLoading,
isJournalLoading,
isSettingsLoading,
isNewMode: !journalId,
submitPayload,
setSubmitPayload
};
return (
<DashboardInsider
loading={
isJournalLoading ||
isAccountsLoading ||
isCurrenciesLoading ||
isContactsLoading ||
isSettingsLoading
}
name={'make-journal-page'}
>
<MakeJournalFormContext.Provider value={provider} {...props} />
</DashboardInsider>
);
}
const useMakeJournalFormContext = () =>
React.useContext(MakeJournalFormContext);
export { MakeJournalProvider, useMakeJournalFormContext };

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { Intent, Position, Button, Tooltip } from '@blueprintjs/core';
import { FormattedMessage as T } from 'components';
import { Icon, Money, Hint } from 'components';
import intl from 'react-intl-universal';
import {
AccountsListFieldCell,
MoneyFieldCell,
InputGroupCell,
ContactsListFieldCell,
} from 'components/DataTableCells';
import { safeSumBy } from 'utils';
/**
* Contact header cell.
*/
export function ContactHeaderCell() {
return (
<>
<T id={'contact'} />
<Hint
content={<T id={'contact_column_hint'} />}
position={Position.LEFT_BOTTOM}
/>
</>
);
}
/**
* Credit header cell.
*/
export function CreditHeaderCell({ payload: { currencyCode } }) {
return intl.get('credit_currency', { currency: currencyCode });
}
/**
* debit header cell.
*/
export function DebitHeaderCell({ payload: { currencyCode } }) {
return intl.get('debit_currency', { currency: currencyCode });
}
/**
* Account footer cell.
*/
function AccountFooterCell({ payload: { currencyCode } }) {
return (
<span>
{intl.get('total_currency', { currency: currencyCode })}
</span>
);
}
/**
* Total credit table footer cell.
*/
function TotalCreditFooterCell({ payload: { currencyCode }, rows }) {
const credit = safeSumBy(rows, 'original.credit');
return (
<span>
<Money amount={credit} currency={currencyCode} />
</span>
);
}
/**
* Total debit table footer cell.
*/
function TotalDebitFooterCell({ payload: { currencyCode }, rows }) {
const debit = safeSumBy(rows, 'original.debit');
return (
<span>
<Money amount={debit} currency={currencyCode} />
</span>
);
}
/**
* Actions cell renderer.
*/
export const ActionsCellRenderer = ({
row: { index },
column: { id },
cell: { value: initialValue },
data,
payload,
}) => {
const onClickRemoveRole = () => {
payload.removeRow(index);
};
return (
<Tooltip content={<T id={'remove_the_line'} />} position={Position.LEFT}>
<Button
icon={<Icon icon="times-circle" iconSize={14} />}
iconSize={14}
className="ml2"
minimal={true}
intent={Intent.DANGER}
onClick={onClickRemoveRole}
/>
</Tooltip>
);
};
/**
* Retrieve columns of make journal entries table.
*/
export const useJournalTableEntriesColumns = () => {
return React.useMemo(
() => [
{
Header: '#',
accessor: 'index',
Cell: ({ row: { index } }) => <span>{index + 1}</span>,
className: 'index',
width: 40,
disableResizing: true,
disableSortBy: true,
sticky: 'left',
},
{
Header: intl.get('account'),
id: 'account_id',
accessor: 'account_id',
Cell: AccountsListFieldCell,
Footer: AccountFooterCell,
className: 'account',
disableSortBy: true,
width: 160,
},
{
Header: CreditHeaderCell,
accessor: 'credit',
Cell: MoneyFieldCell,
Footer: TotalCreditFooterCell,
className: 'credit',
disableSortBy: true,
width: 100,
},
{
Header: DebitHeaderCell,
accessor: 'debit',
Cell: MoneyFieldCell,
Footer: TotalDebitFooterCell,
className: 'debit',
disableSortBy: true,
width: 100,
},
{
Header: ContactHeaderCell,
id: 'contact_id',
accessor: 'contact_id',
Cell: ContactsListFieldCell,
className: 'contact',
disableSortBy: true,
width: 120,
},
{
Header: intl.get('note'),
accessor: 'note',
Cell: InputGroupCell,
disableSortBy: true,
className: 'note',
width: 200,
},
{
Header: '',
accessor: 'action',
Cell: ActionsCellRenderer,
className: 'actions',
disableSortBy: true,
disableResizing: true,
width: 45,
},
],
[],
);
};

View File

@@ -0,0 +1,194 @@
import React from 'react';
import { Intent } from '@blueprintjs/core';
import { sumBy, setWith, toSafeInteger, get } from 'lodash';
import moment from 'moment';
import * as R from 'ramda';
import {
transactionNumber,
updateTableCell,
repeatValue,
transformToForm,
defaultFastFieldShouldUpdate,
ensureEntriesHasEmptyLine
} from 'utils';
import { AppToaster } from 'components';
import intl from 'react-intl-universal';
import { useFormikContext } from 'formik';
const ERROR = {
JOURNAL_NUMBER_ALREADY_EXISTS: 'JOURNAL.NUMBER.ALREADY.EXISTS',
CUSTOMERS_NOT_WITH_RECEVIABLE_ACC: 'CUSTOMERS.NOT.WITH.RECEIVABLE.ACCOUNT',
VENDORS_NOT_WITH_PAYABLE_ACCOUNT: 'VENDORS.NOT.WITH.PAYABLE.ACCOUNT',
PAYABLE_ENTRIES_HAS_NO_VENDORS: 'PAYABLE.ENTRIES.HAS.NO.VENDORS',
RECEIVABLE_ENTRIES_HAS_NO_CUSTOMERS: 'RECEIVABLE.ENTRIES.HAS.NO.CUSTOMERS',
CREDIT_DEBIT_SUMATION_SHOULD_NOT_EQUAL_ZERO:
'CREDIT.DEBIT.SUMATION.SHOULD.NOT.EQUAL.ZERO',
ENTRIES_SHOULD_ASSIGN_WITH_CONTACT: 'ENTRIES_SHOULD_ASSIGN_WITH_CONTACT',
};
export const MIN_LINES_NUMBER = 4;
export const defaultEntry = {
account_id: '',
credit: '',
debit: '',
contact_id: '',
note: '',
};
export const defaultManualJournal = {
journal_number: '',
journal_type: 'Journal',
date: moment(new Date()).format('YYYY-MM-DD'),
description: '',
reference: '',
currency_code: '',
publish: '',
entries: [...repeatValue(defaultEntry, 4)],
};
// Transform to edit form.
export function transformToEditForm(manualJournal) {
const defaultEntry = defaultManualJournal.entries[0];
const initialEntries = [
...manualJournal.entries.map((entry) => ({
...transformToForm(entry, defaultEntry),
})),
...repeatValue(
defaultEntry,
Math.max(MIN_LINES_NUMBER - manualJournal.entries.length, 0),
),
];
const entries = R.compose(
ensureEntriesHasEmptyLine(MIN_LINES_NUMBER, defaultEntry),
)(initialEntries);
return {
...transformToForm(manualJournal, defaultManualJournal),
entries,
};
}
/**
* Entries adjustment.
*/
function adjustmentEntries(entries) {
const credit = sumBy(entries, (e) => toSafeInteger(e.credit));
const debit = sumBy(entries, (e) => toSafeInteger(e.debit));
return {
debit: Math.max(credit - debit, 0),
credit: Math.max(debit - credit, 0),
};
}
/**
* Adjustment credit/debit entries.
* @param {number} rowIndex
* @param {number} columnId
* @param {string} value
* @return {array}
*/
export const updateAdjustEntries = (rowIndex, columnId, value) => (rows) => {
let newRows = [...rows];
const oldCredit = get(rows, `[${rowIndex}].credit`);
const oldDebit = get(rows, `[${rowIndex}].debit`);
if (columnId === 'account_id' && !oldCredit && !oldDebit) {
const adjustment = adjustmentEntries(rows);
if (adjustment.credit) {
newRows = updateTableCell(rowIndex, 'credit', adjustment.credit)(newRows);
}
if (adjustment.debit) {
newRows = updateTableCell(rowIndex, 'debit', adjustment.debit)(newRows);
}
}
return newRows;
};
/**
* Transform API errors in toasts messages.
*/
export const transformErrors = (resErrors, { setErrors, errors }) => {
const getError = (errorType) => resErrors.find((e) => e.type === errorType);
const toastMessages = [];
let error;
let newErrors = { ...errors, entries: [] };
const setEntriesErrors = (indexes, prop, message) =>
indexes.forEach((i) => {
const index = Math.max(i - 1, 0);
newErrors = setWith(newErrors, `entries.[${index}].${prop}`, message);
});
if ((error = getError(ERROR.RECEIVABLE_ENTRIES_HAS_NO_CUSTOMERS))) {
toastMessages.push(
intl.get('should_select_customers_with_entries_have_receivable_account'),
);
setEntriesErrors(error.indexes, 'contact_id', 'error');
}
if ((error = getError(ERROR.ENTRIES_SHOULD_ASSIGN_WITH_CONTACT))) {
if (error.meta.find((meta) => meta.contact_type === 'customer')) {
toastMessages.push(
intl.get('receivable_accounts_should_assign_with_customers'),
);
}
if (error.meta.find((meta) => meta.contact_type === 'vendor')) {
toastMessages.push(
intl.get('payable_accounts_should_assign_with_vendors'),
);
}
const indexes = error.meta.map((meta) => meta.indexes).flat();
setEntriesErrors(indexes, 'contact_id', 'error');
}
if ((error = getError(ERROR.JOURNAL_NUMBER_ALREADY_EXISTS))) {
newErrors = setWith(
newErrors,
'journal_number',
intl.get('journal_number_is_already_used'),
);
}
setErrors({ ...newErrors });
if (toastMessages.length > 0) {
AppToaster.show({
message: toastMessages.map((message) => {
return <div>{message}</div>;
}),
intent: Intent.DANGER,
});
}
};
export const useObserveJournalNoSettings = (prefix, nextNumber) => {
const { setFieldValue } = useFormikContext();
React.useEffect(() => {
const journalNo = transactionNumber(prefix, nextNumber);
setFieldValue('journal_number', journalNo);
}, [setFieldValue, prefix, nextNumber]);
};
/**
* Detarmines entries fast field should update.
*/
export const entriesFieldShouldUpdate = (newProps, oldProps) => {
return (
newProps.accounts !== oldProps.accounts ||
newProps.contacts !== oldProps.contacts ||
defaultFastFieldShouldUpdate(newProps, oldProps)
);
};
/**
* Detarmines currencies fast field should update.
*/
export const currenciesFieldShouldUpdate = (newProps, oldProps) => {
return (
newProps.currencies !== oldProps.currencies ||
defaultFastFieldShouldUpdate(newProps, oldProps)
);
};

View File

@@ -0,0 +1,47 @@
import intl from 'react-intl-universal';
import { RESOURCES_TYPES } from 'common/resourcesTypes';
import withDrawerActions from '../Drawer/withDrawerActions';
/**
* Universal search manual journal item select action.
*/
function JournalUniversalSearchSelectComponent({
// #ownProps
resourceType,
resourceId,
onAction,
// #withDrawerActions
openDrawer,
}) {
if (resourceType === RESOURCES_TYPES.MANUAL_JOURNAL) {
openDrawer('journal-drawer', { manualJournalId: resourceId });
onAction && onAction();
}
return null;
}
export const JournalUniversalSearchSelectAction = withDrawerActions(
JournalUniversalSearchSelectComponent,
);
/**
* Mappes the manual journal item to search item.
*/
const manualJournalsToSearch = (manualJournal) => ({
id: manualJournal.id,
text: manualJournal.journal_number,
subText: manualJournal.formatted_date,
label: manualJournal.formatted_amount,
reference: manualJournal,
});
/**
* Binds universal search invoice configure.
*/
export const universalSearchJournalBind = () => ({
resourceType: RESOURCES_TYPES.MANUAL_JOURNAL,
optionItemLabel: intl.get('manual_journals'),
selectItemAction: JournalUniversalSearchSelectAction,
itemSelect: manualJournalsToSearch,
});