re-structure to monorepo.

This commit is contained in:
a.bouhuolia
2023-02-03 01:02:31 +02:00
parent 8242ec64ba
commit 7a0a13f9d5
10400 changed files with 46966 additions and 17223 deletions

View File

@@ -0,0 +1,195 @@
// @ts-nocheck
import React from 'react';
import {
Intent,
Button,
ButtonGroup,
Popover,
PopoverInteractionKind,
Position,
Menu,
MenuItem,
} from '@blueprintjs/core';
import { useFormikContext } from 'formik';
import { FormattedMessage as T } from '@/components';
import { useHistory } from 'react-router-dom';
import { CLASSES } from '@/constants/classes';
import classNames from 'classnames';
import { Icon, If } from '@/components';
import { useExpenseFormContext } from './ExpenseFormPageProvider';
/**
* Expense form floating actions.
*/
export default function ExpenseFloatingFooter() {
const history = useHistory();
// Formik context.
const { isSubmitting, submitForm, resetForm } = useFormikContext();
// Expense form context.
const { setSubmitPayload, isNewMode } = useExpenseFormContext();
// 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 });
submitForm();
};
// 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 click.
const handleCancelBtnClick = (event) => {
history.goBack();
};
// Handles clear form button click.
const handleClearBtnClick = (event) => {
resetForm();
};
return (
<div className={classNames(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
{/* ----------- Save And Publish ----------- */}
<If condition={isNewMode}>
<ButtonGroup>
<Button
disabled={isSubmitting}
loading={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
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'}
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={!isNewMode}>
<ButtonGroup>
<Button
disabled={isSubmitting}
loading={isSubmitting}
intent={Intent.PRIMARY}
onClick={handleSubmitPublishBtnClick}
style={{ minWidth: '85px' }}
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={!isNewMode ? <T id={'reset'} /> : <T id={'clear'} />}
/>
{/* ----------- Cancel ----------- */}
<Button
className={'ml1'}
onClick={handleCancelBtnClick}
text={<T id={'cancel'} />}
/>
</div>
);
}

View File

@@ -0,0 +1,43 @@
// @ts-nocheck
import * as Yup from 'yup';
import intl from 'react-intl-universal';
import { DATATYPES_LENGTH } from '@/constants/dataTypes';
import { isBlank } from '@/utils';
const Schema = Yup.object().shape({
beneficiary: Yup.string().label(intl.get('beneficiary')),
payment_account_id: Yup.number()
.required()
.label(intl.get('payment_account_')),
payment_date: Yup.date().required().label(intl.get('payment_date_')),
reference_no: Yup.string().min(1).max(DATATYPES_LENGTH.STRING).nullable(),
currency_code: Yup.string()
.nullable()
.max(3)
.label(intl.get('currency_code')),
description: Yup.string()
.trim()
.min(1)
.max(DATATYPES_LENGTH.TEXT)
.nullable()
.label(intl.get('description')),
publish: Yup.boolean(),
categories: Yup.array().of(
Yup.object().shape({
index: Yup.number().min(1).max(DATATYPES_LENGTH.INT_10).nullable(),
amount: Yup.number().nullable(),
expense_account_id: Yup.number()
.nullable()
.when(['amount'], {
is: (amount) => !isBlank(amount),
then: Yup.number().required(),
}),
landed_cost: Yup.boolean(),
description: Yup.string().max(DATATYPES_LENGTH.TEXT).nullable(),
project_id: Yup.number().nullable(),
}),
),
});
export const CreateExpenseFormSchema = Schema;
export const EditExpenseFormSchema = Schema;

View File

@@ -0,0 +1,167 @@
// @ts-nocheck
import React, { useMemo } from 'react';
import intl from 'react-intl-universal';
import classNames from 'classnames';
import { Intent } from '@blueprintjs/core';
import { defaultTo, sumBy, isEmpty } from 'lodash';
import { Formik, Form } from 'formik';
import { useHistory } from 'react-router-dom';
import { CLASSES } from '@/constants/classes';
import ExpenseFormBody from './ExpenseFormBody';
import ExpenseFormHeader from './ExpenseFormHeader';
import ExpenseFloatingFooter from './ExpenseFloatingActions';
import ExpenseFormFooter from './ExpenseFormFooter';
import ExpenseFormTopBar from './ExpenseFormTopBar';
import { useExpenseFormContext } from './ExpenseFormPageProvider';
import withDashboardActions from '@/containers/Dashboard/withDashboardActions';
import withSettings from '@/containers/Settings/withSettings';
import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization';
import { AppToaster } from '@/components';
import {
CreateExpenseFormSchema,
EditExpenseFormSchema,
} from './ExpenseForm.schema';
import {
transformErrors,
defaultExpense,
transformToEditForm,
transformFormValuesToRequest,
} from './utils';
import { compose } from '@/utils';
/**
* Expense form.
*/
function ExpenseForm({
// #withSettings
preferredPaymentAccount,
// #withCurrentOrganization
organization: { base_currency },
}) {
// Expense form context.
const {
editExpenseMutate,
createExpenseMutate,
expense,
expenseId,
submitPayload,
} = useExpenseFormContext();
const isNewMode = !expenseId;
// History context.
const history = useHistory();
// Form initial values.
const initialValues = useMemo(
() => ({
...(!isEmpty(expense)
? {
...transformToEditForm(expense, defaultExpense),
}
: {
...defaultExpense,
currency_code: base_currency,
payment_account_id: defaultTo(preferredPaymentAccount, ''),
}),
}),
[expense, base_currency, preferredPaymentAccount],
);
// Handle form submit.
const handleSubmit = (values, { setSubmitting, setErrors, resetForm }) => {
setSubmitting(true);
const totalAmount = sumBy(values.categories, 'amount');
if (totalAmount <= 0) {
AppToaster.show({
message: intl.get('amount_cannot_be_zero_or_empty'),
intent: Intent.DANGER,
});
return;
}
const form = {
...transformFormValuesToRequest(values),
publish: submitPayload.publish,
};
// Handle request success.
const handleSuccess = (response) => {
AppToaster.show({
message: intl.get(
isNewMode
? 'the_expense_has_been_created_successfully'
: 'the_expense_has_been_edited_successfully',
{ number: values.payment_account_id },
),
intent: Intent.SUCCESS,
});
setSubmitting(false);
if (submitPayload.redirect) {
history.push('/expenses');
}
if (submitPayload.resetForm) {
resetForm();
}
};
// Handle the request error.
const handleError = ({
response: {
data: { errors },
},
}) => {
transformErrors(errors, { setErrors });
setSubmitting(false);
};
if (isNewMode) {
createExpenseMutate(form).then(handleSuccess).catch(handleError);
} else {
editExpenseMutate([expense.id, form])
.then(handleSuccess)
.catch(handleError);
}
};
return (
<div
className={classNames(
CLASSES.PAGE_FORM,
CLASSES.PAGE_FORM_STRIP_STYLE,
CLASSES.PAGE_FORM_EXPENSE,
)}
>
<Formik
validationSchema={
isNewMode ? CreateExpenseFormSchema : EditExpenseFormSchema
}
initialValues={initialValues}
onSubmit={handleSubmit}
>
<Form>
<ExpenseFormTopBar />
<ExpenseFormHeader />
<ExpenseFormBody />
<ExpenseFormFooter />
<ExpenseFloatingFooter />
</Form>
</Formik>
</div>
);
}
export default compose(
withDashboardActions,
withSettings(({ expenseSettings }) => ({
preferredPaymentAccount: parseInt(
expenseSettings?.preferredPaymentAccount,
10,
),
})),
withCurrentOrganization(),
)(ExpenseForm);

View File

@@ -0,0 +1,13 @@
// @ts-nocheck
import React from 'react';
import classNames from 'classnames';
import { CLASSES } from '@/constants/classes';
import ExpenseFormEntriesField from './ExpenseFormEntriesField';
export default function ExpenseFormBody() {
return (
<div className={classNames(CLASSES.PAGE_FORM_BODY)}>
<ExpenseFormEntriesField />
</div>
);
}

View File

@@ -0,0 +1,40 @@
// @ts-nocheck
import { FastField } from 'formik';
import React from 'react';
import ExpenseFormEntriesTable from './ExpenseFormEntriesTable';
import { useExpenseFormContext } from './ExpenseFormPageProvider';
import { defaultExpenseEntry, accountsFieldShouldUpdate } from './utils';
/**
* Expense form entries field.
*/
export default function ExpenseFormEntriesField({ linesNumber = 4 }) {
// Expense form context.
const { accounts, projects } = useExpenseFormContext();
return (
<FastField
name={'categories'}
accounts={accounts}
projects={projects}
shouldUpdate={accountsFieldShouldUpdate}
>
{({
form: { values, setFieldValue },
field: { value },
meta: { error, touched },
}) => (
<ExpenseFormEntriesTable
entries={value}
error={error}
onChange={(entries) => {
setFieldValue('categories', entries);
}}
defaultEntry={defaultExpenseEntry}
linesNumber={linesNumber}
currencyCode={values.currency_code}
/>
)}
</FastField>
);
}

View File

@@ -0,0 +1,86 @@
// @ts-nocheck
import React, { useCallback } from 'react';
import { DataTableEditable } from '@/components';
import { useExpenseFormContext } from './ExpenseFormPageProvider';
import { useExpenseFormTableColumns } from './components';
import {
saveInvoke,
compose,
updateTableCell,
updateMinEntriesLines,
updateAutoAddNewLine,
updateRemoveLineByIndex,
} from '@/utils';
/**
* Expenses form entries.
*/
export default function ExpenseFormEntriesTable({
// #ownPorps
entries,
defaultEntry,
error,
onChange,
currencyCode,
landedCost = true,
minLines,
}) {
// Expense form context.
const { accounts, projects } = useExpenseFormContext();
// Memorized data table columns.
const columns = useExpenseFormTableColumns({ landedCost });
// Handles update datatable data.
const handleUpdateData = useCallback(
(rowIndex, columnId, value) => {
const newRows = compose(
// Update auto-adding new line.
updateAutoAddNewLine(defaultEntry, ['expense_account_id']),
// Update the row value of the given row index and column id.
updateTableCell(rowIndex, columnId, value),
)(entries);
saveInvoke(onChange, newRows);
},
[entries, defaultEntry, onChange],
);
// Handles click remove datatable row.
const handleRemoveRow = useCallback(
(rowIndex) => {
const newRows = compose(
// Ensure minimum lines count.
updateMinEntriesLines(minLines, defaultEntry),
// Remove the line by the given index.
updateRemoveLineByIndex(rowIndex),
)(entries);
saveInvoke(onChange, newRows);
},
[minLines, entries, defaultEntry, onChange],
);
return (
<DataTableEditable
name={'expense-form'}
columns={columns}
data={entries}
sticky={true}
payload={{
accounts: accounts,
projects: projects,
errors: error,
updateData: handleUpdateData,
removeRow: handleRemoveRow,
autoFocus: ['expense_account_id', 0],
currencyCode,
}}
/>
);
}
ExpenseFormEntriesTable.defaultProps = {
minLines: 1,
};

View File

@@ -0,0 +1,31 @@
// @ts-nocheck
import React from 'react';
import classNames from 'classnames';
import styled from 'styled-components';
import { CLASSES } from '@/constants/classes';
import { Row, Col, Paper } from '@/components';
import { ExpenseFormFooterLeft } from './ExpenseFormFooterLeft';
import { ExpenseFormFooterRight } from './ExpenseFormFooterRight';
export default function ExpenseFormFooter() {
return (
<div className={classNames(CLASSES.PAGE_FORM_FOOTER)}>
<ExpensesFooterPaper>
<Row>
<Col md={8}>
<ExpenseFormFooterLeft />
</Col>
<Col md={4}>
<ExpenseFormFooterRight />
</Col>
</Row>
</ExpensesFooterPaper>
</div>
);
}
const ExpensesFooterPaper = styled(Paper)`
padding: 20px;
`;

View File

@@ -0,0 +1,32 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import { FFormGroup, FEditableText, FormattedMessage as T } from '@/components';
export function ExpenseFormFooterLeft() {
return (
<React.Fragment>
{/* --------- Description --------- */}
<DescriptionFormGroup
label={<T id={'description'} />}
name={'description'}
>
<FEditableText
name={'description'}
placeholder={<T id={'expenses.decscrption.placeholder'} />}
/>
</DescriptionFormGroup>
</React.Fragment>
);
}
const DescriptionFormGroup = styled(FFormGroup)`
&.bp3-form-group {
.bp3-label {
font-size: 12px;
margin-bottom: 12px;
}
.bp3-form-content {
margin-left: 10px;
}
}
`;

View File

@@ -0,0 +1,35 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import {
T,
TotalLines,
TotalLine,
TotalLineBorderStyle,
TotalLineTextStyle,
} from '@/components';
import { useExpensesTotals } from './utils';
export function ExpenseFormFooterRight() {
const { formattedSubtotal, formattedTotal } = useExpensesTotals();
return (
<ExpensesTotalLines>
<TotalLine
title={<T id={'expense.label.subtotal'} />}
value={formattedSubtotal}
borderStyle={TotalLineBorderStyle.None}
/>
<TotalLine
title={<T id={'expense.label.total'} />}
value={formattedTotal}
textStyle={TotalLineTextStyle.Bold}
/>
</ExpensesTotalLines>
);
}
const ExpensesTotalLines = styled(TotalLines)`
width: 100%;
color: #555555;
`;

View File

@@ -0,0 +1,35 @@
// @ts-nocheck
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { sumBy } from 'lodash';
import { useFormikContext } from 'formik';
import { FormattedMessage as T } from '@/components';
import { CLASSES } from '@/constants/classes';
import ExpenseFormHeaderFields from './ExpenseFormHeaderFields';
import { PageFormBigNumber } from '@/components';
// Expense form header.
export default function ExpenseFormHeader() {
const {
values: { currency_code, categories },
} = useFormikContext();
// Calculates the expense entries amount.
const totalExpenseAmount = useMemo(
() => sumBy(categories, 'amount'),
[categories],
);
return (
<div className={classNames(CLASSES.PAGE_FORM_HEADER)}>
<ExpenseFormHeaderFields />
<PageFormBigNumber
label={<T id={'expense_amount'} />}
amount={totalExpenseAmount}
currencyCode={currency_code}
/>
</div>
);
}

View File

@@ -0,0 +1,163 @@
// @ts-nocheck
import React from 'react';
import { InputGroup, FormGroup, Position, Classes } from '@blueprintjs/core';
import { DateInput } from '@blueprintjs/datetime';
import { FastField, ErrorMessage } from 'formik';
import { FormattedMessage as T } from '@/components';
import classNames from 'classnames';
import { CLASSES } from '@/constants/classes';
import {
momentFormatter,
tansformDateValue,
inputIntent,
handleDateChange,
} from '@/utils';
import { customersFieldShouldUpdate, accountsFieldShouldUpdate } from './utils';
import {
CurrencySelectList,
CustomerSelectField,
AccountsSelectList,
FieldRequiredHint,
Hint,
} from '@/components';
import { ExpensesExchangeRateInputField } from './components';
import { ACCOUNT_PARENT_TYPE } from '@/constants/accountTypes';
import { useExpenseFormContext } from './ExpenseFormPageProvider';
/**
* Expense form header.
*/
export default function ExpenseFormHeader() {
const { currencies, accounts, customers } = useExpenseFormContext();
return (
<div className={classNames(CLASSES.PAGE_FORM_HEADER_FIELDS)}>
<FastField name={'payment_date'}>
{({ form, field: { value }, meta: { error, touched } }) => (
<FormGroup
label={<T id={'payment_date'} />}
labelInfo={<Hint />}
className={classNames('form-group--select-list', Classes.FILL)}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="payment_date" />}
inline={true}
>
<DateInput
{...momentFormatter('YYYY/MM/DD')}
value={tansformDateValue(value)}
onChange={handleDateChange((formattedDate) => {
form.setFieldValue('payment_date', formattedDate);
})}
popoverProps={{ position: Position.BOTTOM, minimal: true }}
/>
</FormGroup>
)}
</FastField>
<FastField
name={'payment_account_id'}
accounts={accounts}
shouldUpdate={accountsFieldShouldUpdate}
>
{({ form, field: { value }, meta: { error, touched } }) => (
<FormGroup
label={<T id={'payment_account'} />}
className={classNames(
'form-group--payment_account',
'form-group--select-list',
Classes.FILL,
)}
labelInfo={<FieldRequiredHint />}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name={'payment_account_id'} />}
inline={true}
>
<AccountsSelectList
accounts={accounts}
onAccountSelected={(account) => {
form.setFieldValue('payment_account_id', account.id);
}}
defaultSelectText={<T id={'select_payment_account'} />}
selectedAccountId={value}
filterByParentTypes={[ACCOUNT_PARENT_TYPE.CURRENT_ASSET]}
allowCreate={true}
/>
</FormGroup>
)}
</FastField>
<FastField name={'currency_code'}>
{({ form, field: { value }, meta: { error, touched } }) => (
<FormGroup
label={<T id={'currency'} />}
className={classNames(
'form-group--select-list',
'form-group--currency',
Classes.FILL,
)}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="currency_code" />}
inline={true}
>
<CurrencySelectList
currenciesList={currencies}
selectedCurrencyCode={value}
onCurrencySelected={(currencyItem) => {
form.setFieldValue('currency_code', currencyItem.currency_code);
}}
defaultSelectText={value}
/>
</FormGroup>
)}
</FastField>
{/* ----------- Exchange rate ----------- */}
<ExpensesExchangeRateInputField
name={'exchange_rate'}
formGroupProps={{ label: ' ', inline: true }}
/>
<FastField name={'reference_no'}>
{({ form, field, meta: { error, touched } }) => (
<FormGroup
label={<T id={'reference_no'} />}
className={classNames('form-group--ref_no', Classes.FILL)}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="reference_no" />}
inline={true}
>
<InputGroup minimal={true} {...field} />
</FormGroup>
)}
</FastField>
<FastField
name={'customer_id'}
customers={customers}
shouldUpdate={customersFieldShouldUpdate}
>
{({ form, field: { value }, meta: { error, touched } }) => (
<FormGroup
label={<T id={'customer'} />}
className={classNames('form-group--select-list', Classes.FILL)}
labelInfo={<Hint />}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name={'assign_to_customer'} />}
inline={true}
>
<CustomerSelectField
contacts={customers}
selectedContactId={value}
defaultSelectText={<T id={'select_customer_account'} />}
onContactSelected={(customer) => {
form.setFieldValue('customer_id', customer.id);
}}
allowCreate={true}
popoverFill={true}
/>
</FormGroup>
)}
</FastField>
</div>
);
}

View File

@@ -0,0 +1,22 @@
// @ts-nocheck
import React from 'react';
import { useParams } from 'react-router-dom';
import '@/style/pages/Expense/PageForm.scss';
import ExpenseForm from './ExpenseForm';
import { ExpenseFormPageProvider } from './ExpenseFormPageProvider';
/**
* Expense page form.
*/
export default function ExpenseFormPage() {
const { id } = useParams();
const expenseId = parseInt(id, 10);
return (
<ExpenseFormPageProvider expenseId={expenseId}>
<ExpenseForm />
</ExpenseFormPageProvider>
);
}

View File

@@ -0,0 +1,109 @@
// @ts-nocheck
import React, { createContext } from 'react';
import { DashboardInsider } from '@/components/Dashboard';
import { Features } from '@/constants';
import { useFeatureCan } from '@/hooks/state';
import {
useCurrencies,
useCustomers,
useExpense,
useAccounts,
useBranches,
useCreateExpense,
useEditExpense,
} from '@/hooks/query';
import { useProjects } from '@/containers/Projects/hooks';
const ExpenseFormPageContext = createContext();
/**
* Accounts chart data provider.
*/
function ExpenseFormPageProvider({ query, expenseId, ...props }) {
// Features guard.
const { featureCan } = useFeatureCan();
const isBranchFeatureCan = featureCan(Features.Branches);
const isProjectsFeatureCan = featureCan(Features.Projects);
const { data: currencies, isLoading: isCurrenciesLoading } = useCurrencies();
// Fetches customers list.
const {
data: { customers },
isLoading: isCustomersLoading,
} = useCustomers();
// Fetch the expense details.
const { data: expense, isLoading: isExpenseLoading } = useExpense(expenseId, {
enabled: !!expenseId,
});
// Fetches the branches list.
const {
data: branches,
isLoading: isBranchesLoading,
isSuccess: isBranchesSuccess,
} = useBranches(query, { enabled: isBranchFeatureCan });
// Fetch accounts list.
const { data: accounts, isLoading: isAccountsLoading } = useAccounts();
// Fetch the projects list.
const {
data: { projects },
isLoading: isProjectsLoading,
} = useProjects({}, { enabled: !!isProjectsFeatureCan });
// Create and edit expense mutate.
const { mutateAsync: createExpenseMutate } = useCreateExpense();
const { mutateAsync: editExpenseMutate } = useEditExpense();
// Submit form payload.
const [submitPayload, setSubmitPayload] = React.useState({});
// Detarmines whether the form in new mode.
const isNewMode = !expenseId;
// Provider payload.
const provider = {
isNewMode,
expenseId,
submitPayload,
currencies,
customers,
expense,
accounts,
branches,
projects,
isCurrenciesLoading,
isExpenseLoading,
isCustomersLoading,
isAccountsLoading,
isBranchesSuccess,
createExpenseMutate,
editExpenseMutate,
setSubmitPayload,
};
return (
<DashboardInsider
loading={
isCurrenciesLoading ||
isExpenseLoading ||
isCustomersLoading ||
isAccountsLoading ||
isProjectsLoading
}
name={'expense-form'}
>
<ExpenseFormPageContext.Provider value={provider} {...props} />
</DashboardInsider>
);
}
const useExpenseFormContext = () => React.useContext(ExpenseFormPageContext);
export { ExpenseFormPageProvider, useExpenseFormContext };

View File

@@ -0,0 +1,69 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import { Button, Alignment, NavbarGroup, Classes } from '@blueprintjs/core';
import { useSetPrimaryBranchToForm } from './utils';
import { useFeatureCan } from '@/hooks/state';
import { Features } from '@/constants';
import {
Icon,
BranchSelect,
FeatureCan,
FormTopbar,
DetailsBarSkeletonBase,
} from '@/components';
import { useExpenseFormContext } from './ExpenseFormPageProvider';
/**
* Expenses form topbar.
* @returns
*/
export default function ExpenseFormTopBar() {
// Features guard.
const { featureCan } = useFeatureCan();
// Sets the primary branch to form.
useSetPrimaryBranchToForm();
// Can't display the navigation bar if branches feature is not enabled.
if (!featureCan(Features.Branches)) {
return null;
}
return (
<FormTopbar>
<NavbarGroup align={Alignment.LEFT}>
<FeatureCan feature={Features.Branches}>
<ExpenseFormSelectBranch />
</FeatureCan>
</NavbarGroup>
</FormTopbar>
);
}
function ExpenseFormSelectBranch() {
// Invoice form context.
const { branches, isBranchesLoading } = useExpenseFormContext();
return isBranchesLoading ? (
<DetailsBarSkeletonBase className={Classes.SKELETON} />
) : (
<BranchSelect
name={'branch_id'}
branches={branches}
input={ExpenseBranchSelectButton}
popoverProps={{ minimal: true }}
/>
);
}
function ExpenseBranchSelectButton({ label }) {
return (
<Button
text={intl.get('expense.branch_button.label', { label })}
minimal={true}
small={true}
icon={<Icon icon={'branch-16'} iconSize={16} />}
/>
);
}

View File

@@ -0,0 +1,187 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import { Button, Menu, MenuItem } from '@blueprintjs/core';
import { Popover2 } from '@blueprintjs/popover2';
import { useFormikContext } from 'formik';
import {
Icon,
Hint,
ExchangeRateInputGroup,
FormattedMessage as T,
} from '@/components';
import {
InputGroupCell,
MoneyFieldCell,
AccountsListFieldCell,
ProjectsListFieldCell,
CheckBoxFieldCell,
} from '@/components/DataTableCells';
import { CellType, Features, Align } from '@/constants';
import { useCurrentOrganization, useFeatureCan } from '@/hooks/state';
import { useExpensesIsForeign } from './utils';
/**
* Expense category header cell.
*/
const ExpenseCategoryHeaderCell = () => {
return (
<>
<T id={'expense_category'} />
<Hint />
</>
);
};
/**
* Actions cell renderer.
*/
const ActionsCellRenderer = ({
row: { index },
column: { id },
cell: { value: initialValue },
data,
payload,
}) => {
const handleClickRemoveRole = () => {
payload.removeRow(index);
};
const exampleMenu = (
<Menu>
<MenuItem
onClick={handleClickRemoveRole}
text={intl.get('expense.entries.remove_row')}
/>
</Menu>
);
return (
<Popover2 content={exampleMenu} placement="left-start">
<Button
icon={<Icon icon={'more-13'} iconSize={13} />}
iconSize={14}
className="m12"
minimal={true}
/>
</Popover2>
);
};
ActionsCellRenderer.cellType = CellType.Button;
/**
* Landed cost header cell.
*/
const LandedCostHeaderCell = () => {
return (
<>
<T id={'landed'} />
<Hint content={<T id={'item_entries.landed.hint'} />} />
</>
);
};
/**
* Expense amount header cell.
*/
export function ExpenseAmountHeaderCell({ payload: { currencyCode } }) {
return intl.get('amount_currency', { currency: currencyCode });
}
/**
* Retrieve expense form table entries columns.
*/
export function useExpenseFormTableColumns({ landedCost }) {
const { featureCan } = useFeatureCan();
return React.useMemo(
() => [
{
Header: ExpenseCategoryHeaderCell,
id: 'expense_account_id',
accessor: 'expense_account_id',
Cell: AccountsListFieldCell,
className: 'expense_account_id',
disableSortBy: true,
width: 40,
filterAccountsByRootTypes: ['expense'],
fieldProps: { allowCreate: true },
},
{
Header: ExpenseAmountHeaderCell,
accessor: 'amount',
Cell: MoneyFieldCell,
disableSortBy: true,
width: 40,
align: Align.Right,
},
{
Header: intl.get('description'),
accessor: 'description',
Cell: InputGroupCell,
disableSortBy: true,
width: 100,
},
...(featureCan(Features.Projects)
? [
{
Header: intl.get('project'),
id: 'project_id',
accessor: 'project_id',
Cell: ProjectsListFieldCell,
className: 'project_id',
disableSortBy: true,
width: 40,
},
]
: []),
...(landedCost
? [
{
Header: LandedCostHeaderCell,
accessor: 'landed_cost',
Cell: CheckBoxFieldCell,
disableSortBy: true,
disableResizing: true,
width: 100,
align: Align.Center,
},
]
: []),
{
Header: '',
accessor: 'action',
Cell: ActionsCellRenderer,
disableSortBy: true,
disableResizing: true,
width: 45,
align: Align.Center,
},
],
[],
);
}
/**
* Expense exchange rate input field.
* @returns {JSX.Element}
*/
export function ExpensesExchangeRateInputField({ ...props }) {
const currentOrganization = useCurrentOrganization();
const { values } = useFormikContext();
const isForeignJouranl = useExpensesIsForeign();
// Can't continue if the customer is not foreign.
if (!isForeignJouranl) {
return null;
}
return (
<ExchangeRateInputGroup
fromCurrency={values.currency_code}
toCurrency={currentOrganization.base_currency}
{...props}
/>
);
}
ExpensesExchangeRateInputField.cellType = CellType.Field;

View File

@@ -0,0 +1,198 @@
// @ts-nocheck
import React from 'react';
import * as R from 'ramda';
import intl from 'react-intl-universal';
import moment from 'moment';
import { AppToaster } from '@/components';
import { Intent } from '@blueprintjs/core';
import { useFormikContext } from 'formik';
import { first, sumBy } from 'lodash';
import { useExpenseFormContext } from './ExpenseFormPageProvider';
import {
defaultFastFieldShouldUpdate,
transformToForm,
repeatValue,
ensureEntriesHasEmptyLine,
orderingLinesIndexes,
formattedAmount,
} from '@/utils';
import { useCurrentOrganization } from '@/hooks/state';
const ERROR = {
EXPENSE_ALREADY_PUBLISHED: 'EXPENSE.ALREADY.PUBLISHED',
ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED:
'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED',
};
export const MIN_LINES_NUMBER = 1;
export const defaultExpenseEntry = {
amount: '',
expense_account_id: '',
description: '',
landed_cost: 0,
project_id: '',
};
export const defaultExpense = {
payment_account_id: '',
beneficiary: '',
payment_date: moment(new Date()).format('YYYY-MM-DD'),
description: '',
reference_no: '',
currency_code: '',
publish: '',
branch_id: '',
exchange_rate: 1,
categories: [...repeatValue(defaultExpenseEntry, MIN_LINES_NUMBER)],
};
/**
* Transform API errors in toasts messages.
*/
export const transformErrors = (errors, { setErrors }) => {
const hasError = (errorType) => errors.some((e) => e.type === errorType);
if (hasError(ERROR.EXPENSE_ALREADY_PUBLISHED)) {
setErrors(
AppToaster.show({
message: intl.get('the_expense_is_already_published'),
}),
);
}
if (hasError(ERROR.ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED)) {
setErrors(
AppToaster.show({
intent: Intent.DANGER,
message: 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED',
}),
);
}
};
/**
* Transformes the expense to form initial values in edit mode.
*/
export const transformToEditForm = (
expense,
defaultExpense,
linesNumber = 4,
) => {
const expenseEntry = defaultExpense.categories[0];
const initialEntries = [
...expense.categories.map((category) => ({
...transformToForm(category, expenseEntry),
})),
...repeatValue(
expenseEntry,
Math.max(linesNumber - expense.categories.length, 0),
),
];
const categories = R.compose(
ensureEntriesHasEmptyLine(MIN_LINES_NUMBER, expenseEntry),
)(initialEntries);
return {
...transformToForm(expense, defaultExpense),
categories,
};
};
/**
* Detarmine cusotmers fast-field should update.
*/
export const customersFieldShouldUpdate = (newProps, oldProps) => {
return (
newProps.customers !== oldProps.customers ||
defaultFastFieldShouldUpdate(newProps, oldProps)
);
};
/**
* Detarmine accounts fast-field should update.
*/
export const accountsFieldShouldUpdate = (newProps, oldProps) => {
return (
newProps.accounts !== oldProps.accounts ||
defaultFastFieldShouldUpdate(newProps, oldProps)
);
};
/**
* Filter expense entries that has no amount or expense account.
*/
export const filterNonZeroEntries = (categories) => {
return categories.filter(
(category) => category.amount && category.expense_account_id,
);
};
/**
* Transformes the form values to request body.
*/
export const transformFormValuesToRequest = (values) => {
const categories = filterNonZeroEntries(values.categories);
return {
...values,
categories: R.compose(orderingLinesIndexes)(categories),
};
};
export const useSetPrimaryBranchToForm = () => {
const { setFieldValue } = useFormikContext();
const { branches, isBranchesSuccess } = useExpenseFormContext();
React.useEffect(() => {
if (isBranchesSuccess) {
const primaryBranch = branches.find((b) => b.primary) || first(branches);
if (primaryBranch) {
setFieldValue('branch_id', primaryBranch.id);
}
}
}, [isBranchesSuccess, setFieldValue, branches]);
};
/**
* Retreives the Journal totals.
*/
export const useExpensesTotals = () => {
const {
values: { categories, currency_code: currencyCode },
} = useFormikContext();
const total = sumBy(categories, 'amount');
// Retrieves the formatted total money.
const formattedTotal = React.useMemo(
() => formattedAmount(total, currencyCode),
[total, currencyCode],
);
// Retrieves the formatted subtotal.
const formattedSubtotal = React.useMemo(
() => formattedAmount(total, currencyCode, { money: false }),
[total, currencyCode],
);
return {
formattedTotal,
formattedSubtotal,
};
};
/**
* Detarmines whether the expenses has foreign .
* @returns {boolean}
*/
export const useExpensesIsForeign = () => {
const { values } = useFormikContext();
const currentOrganization = useCurrentOrganization();
const isForeignExpenses = React.useMemo(
() => values.currency_code !== currentOrganization.base_currency,
[values.currency_code, currentOrganization.base_currency],
);
return isForeignExpenses;
};