mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-14 03:40:31 +00:00
feat: Attachment files system.
This commit is contained in:
11
client/.flowconfig
Normal file
11
client/.flowconfig
Normal file
@@ -0,0 +1,11 @@
|
||||
[ignore]
|
||||
|
||||
[include]
|
||||
|
||||
[libs]
|
||||
|
||||
[lints]
|
||||
|
||||
[options]
|
||||
|
||||
[strict]
|
||||
@@ -40,6 +40,7 @@
|
||||
"eslint-plugin-react": "7.18.0",
|
||||
"eslint-plugin-react-hooks": "^1.6.1",
|
||||
"file-loader": "4.3.0",
|
||||
"flow-bin": "^0.123.0",
|
||||
"formik": "^2.1.4",
|
||||
"fs-extra": "^8.1.0",
|
||||
"html-webpack-plugin": "4.0.0-beta.11",
|
||||
@@ -54,6 +55,7 @@
|
||||
"moment": "^2.24.0",
|
||||
"node-sass": "^4.13.1",
|
||||
"optimize-css-assets-webpack-plugin": "5.0.3",
|
||||
"p-progress": "^0.4.2",
|
||||
"pnp-webpack-plugin": "1.6.0",
|
||||
"postcss-flexbugs-fixes": "4.1.0",
|
||||
"postcss-loader": "3.0.0",
|
||||
@@ -65,6 +67,7 @@
|
||||
"react-body-classname": "^1.3.1",
|
||||
"react-dev-utils": "^10.2.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-dropzone": "^11.0.1",
|
||||
"react-grid-system": "^6.2.3",
|
||||
"react-hook-form": "^4.9.4",
|
||||
"react-intl": "^3.12.0",
|
||||
@@ -97,7 +100,8 @@
|
||||
"scripts": {
|
||||
"start": "PORT=8000 node scripts/start.js",
|
||||
"build": "node scripts/build.js",
|
||||
"test": "node scripts/test.js"
|
||||
"test": "node scripts/test.js",
|
||||
"flow": "flow"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
@@ -115,6 +119,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-flow": "^7.9.0",
|
||||
"http-proxy-middleware": "^1.0.0",
|
||||
"redux-devtools": "^3.5.0"
|
||||
},
|
||||
|
||||
77
client/src/components/Dragzone.js
Normal file
77
client/src/components/Dragzone.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { pullAt } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'components/Icon';
|
||||
|
||||
// const initialFile: {
|
||||
// file: ?File,
|
||||
// preview: string,
|
||||
// metadata: ?object,
|
||||
// uploaded: boolean,
|
||||
// };
|
||||
|
||||
export default function Dropzone({
|
||||
text = 'Drag/Drop files here or click here',
|
||||
onDrop,
|
||||
initialFiles = [],
|
||||
onDeleteFile,
|
||||
hint,
|
||||
className,
|
||||
}) {
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setFiles([ ...initialFiles ]);
|
||||
}, [initialFiles]);
|
||||
|
||||
const {getRootProps, getInputProps} = useDropzone({
|
||||
accept: 'image/*',
|
||||
onDrop: (acceptedFiles) => {
|
||||
const _files = acceptedFiles.map((file) => ({
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
uploaded: false,
|
||||
}));
|
||||
setFiles(_files);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemove = useCallback((index) => {
|
||||
const deletedFile = files.splice(index, 1);
|
||||
setFiles([...files]);
|
||||
onDeleteFile && onDeleteFile(deletedFile);
|
||||
}, [files, onDeleteFile]);
|
||||
|
||||
const thumbs = files.map((file, index) => (
|
||||
<div className={'dropzone-thumb'} key={file.name}>
|
||||
<div><img src={file.preview} /></div>
|
||||
<button onClick={() => handleRemove(index)}>
|
||||
<Icon icon={'times'} iconSize={12} />
|
||||
</button>
|
||||
</div>
|
||||
));
|
||||
|
||||
useEffect(() => () => {
|
||||
files.forEach(file => URL.revokeObjectURL(file.preview));
|
||||
}, [files, onDrop]);
|
||||
|
||||
useEffect(() => {
|
||||
onDrop && onDrop(files);
|
||||
}, [files, onDrop]);
|
||||
|
||||
return (
|
||||
<section className={classNames('dropzone-container', className)}>
|
||||
{(hint) && <div class="dropzone-hint">{ hint }</div>}
|
||||
|
||||
<div {...getRootProps({ className: 'dropzone' })}>
|
||||
<input {...getInputProps()} />
|
||||
<p>{ text }</p>
|
||||
</div>
|
||||
|
||||
<div className={'dropzone-thumbs'}>
|
||||
{ thumbs }
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -23,15 +23,34 @@ import Icon from 'components/Icon';
|
||||
import ItemCategoryConnect from 'connectors/ItemsCategory.connect';
|
||||
import MoneyInputGroup from 'components/MoneyInputGroup';
|
||||
import {useHistory} from 'react-router-dom';
|
||||
import Dragzone from 'components/Dragzone';
|
||||
import MediaConnect from 'connectors/Media.connect';
|
||||
import useMedia from 'hooks/useMedia';
|
||||
|
||||
const ItemForm = ({
|
||||
requestSubmitItem,
|
||||
|
||||
accounts,
|
||||
categories,
|
||||
|
||||
requestSubmitMedia,
|
||||
requestDeleteMedia,
|
||||
}) => {
|
||||
const [selectedAccounts, setSelectedAccounts] = useState({});
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
saveMedia,
|
||||
deletedFiles,
|
||||
setDeletedFiles,
|
||||
deleteMedia,
|
||||
} = useMedia({
|
||||
saveCallback: requestSubmitMedia,
|
||||
deleteCallback: requestDeleteMedia,
|
||||
})
|
||||
|
||||
const ItemTypeDisplay = useMemo(() => ([
|
||||
{ value: null, label: 'Select Item Type' },
|
||||
{ value: 'service', label: 'Service' },
|
||||
@@ -85,15 +104,27 @@ const ItemForm = ({
|
||||
...initialValues,
|
||||
},
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
requestSubmitItem(values).then((response) => {
|
||||
AppToaster.show({
|
||||
message: 'The_Items_has_been_Submit'
|
||||
const saveItem = (mediaIds) => {
|
||||
const formValues = { ...values, media_ids: mediaIds };
|
||||
|
||||
return requestSubmitItem(formValues).then((response) => {
|
||||
AppToaster.show({
|
||||
message: 'The_Items_has_been_submit'
|
||||
});
|
||||
setSubmitting(false);
|
||||
history.push('/dashboard/items');
|
||||
})
|
||||
.catch((error) => {
|
||||
setSubmitting(false);
|
||||
});
|
||||
setSubmitting(false);
|
||||
history.push('/dashboard/items');
|
||||
})
|
||||
.catch((error) => {
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
Promise.all([
|
||||
saveMedia(),
|
||||
deleteMedia(),
|
||||
]).then(([savedMediaResponses]) => {
|
||||
const mediaIds = savedMediaResponses.map(res => res.data.media.id);
|
||||
return saveItem(mediaIds);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -139,95 +170,126 @@ const ItemForm = ({
|
||||
setFieldValue(fieldKey, value);
|
||||
};
|
||||
|
||||
const initialAttachmentFiles = useMemo(() => {
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
const handleDropFiles = useCallback((_files) => {
|
||||
setFiles(_files.filter((file) => file.uploaded === false));
|
||||
}, []);
|
||||
|
||||
const handleDeleteFile = useCallback((_deletedFiles) => {
|
||||
_deletedFiles.forEach((deletedFile) => {
|
||||
if (deletedFile.uploaded && deletedFile.metadata.id) {
|
||||
setDeletedFiles([
|
||||
...deletedFiles, deletedFile.metadata.id,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}, [setDeletedFiles, deletedFiles]);
|
||||
|
||||
return (
|
||||
<div class='item-form'>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class="item-form__primary-section">
|
||||
<FormGroup
|
||||
medium={true}
|
||||
label={'Item Type'}
|
||||
labelInfo={requiredSpan}
|
||||
className={'form-group--item-type'}
|
||||
intent={(errors.type && touched.type) && Intent.DANGER}
|
||||
helperText={<ErrorMessage {...{errors, touched}} name="type" />}
|
||||
inline={true}
|
||||
>
|
||||
<HTMLSelect
|
||||
fill={true}
|
||||
options={ItemTypeDisplay}
|
||||
{...getFieldProps('type')}
|
||||
/>
|
||||
</FormGroup>
|
||||
<Row>
|
||||
<Col xs={7}>
|
||||
<FormGroup
|
||||
medium={true}
|
||||
label={'Item Type'}
|
||||
labelInfo={requiredSpan}
|
||||
className={'form-group--item-type'}
|
||||
intent={(errors.type && touched.type) && Intent.DANGER}
|
||||
helperText={<ErrorMessage {...{errors, touched}} name="type" />}
|
||||
inline={true}
|
||||
>
|
||||
<HTMLSelect
|
||||
fill={true}
|
||||
options={ItemTypeDisplay}
|
||||
{...getFieldProps('type')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label={'Item Name'}
|
||||
labelInfo={requiredSpan}
|
||||
className={'form-group--item-name'}
|
||||
intent={(errors.name && touched.name) && Intent.DANGER}
|
||||
helperText={<ErrorMessage {...{errors, touched}} name="name" />}
|
||||
inline={true}
|
||||
>
|
||||
<InputGroup
|
||||
medium={true}
|
||||
intent={(errors.name && touched.name) && Intent.DANGER}
|
||||
{...getFieldProps('name')}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={'Item Name'}
|
||||
labelInfo={requiredSpan}
|
||||
className={'form-group--item-name'}
|
||||
intent={(errors.name && touched.name) && Intent.DANGER}
|
||||
helperText={<ErrorMessage {...{errors, touched}} name="name" />}
|
||||
inline={true}
|
||||
>
|
||||
<InputGroup
|
||||
medium={true}
|
||||
intent={(errors.name && touched.name) && Intent.DANGER}
|
||||
{...getFieldProps('name')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label={'SKU'}
|
||||
labelInfo={infoIcon}
|
||||
className={'form-group--item-sku'}
|
||||
intent={(errors.sku && touched.sku) && Intent.DANGER}
|
||||
helperText={<ErrorMessage {...{errors, touched}} name="sku" />}
|
||||
inline={true}
|
||||
>
|
||||
<InputGroup
|
||||
medium={true}
|
||||
intent={(errors.sku && touched.sku) && Intent.DANGER}
|
||||
{...getFieldProps('sku')}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={'SKU'}
|
||||
labelInfo={infoIcon}
|
||||
className={'form-group--item-sku'}
|
||||
intent={(errors.sku && touched.sku) && Intent.DANGER}
|
||||
helperText={<ErrorMessage {...{errors, touched}} name="sku" />}
|
||||
inline={true}
|
||||
>
|
||||
<InputGroup
|
||||
medium={true}
|
||||
intent={(errors.sku && touched.sku) && Intent.DANGER}
|
||||
{...getFieldProps('sku')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label={'Category'}
|
||||
labelInfo={infoIcon}
|
||||
inline={true}
|
||||
intent={(errors.category_id && touched.category_id) && Intent.DANGER}
|
||||
helperText={<ErrorMessage {...{errors, touched}} name="category" />}
|
||||
className={classNames(
|
||||
'form-group--select-list',
|
||||
'form-group--category',
|
||||
Classes.FILL,
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
items={categories}
|
||||
itemRenderer={categoryItem}
|
||||
itemPredicate={filterAccounts}
|
||||
popoverProps={{ minimal: true }}
|
||||
onItemSelect={onItemAccountSelect('category_id')}
|
||||
>
|
||||
<Button
|
||||
fill={true}
|
||||
rightIcon='caret-down'
|
||||
text={getSelectedAccountLabel('category_id', 'Select category')}
|
||||
/>
|
||||
</Select>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={'Category'}
|
||||
labelInfo={infoIcon}
|
||||
inline={true}
|
||||
intent={(errors.category_id && touched.category_id) && Intent.DANGER}
|
||||
helperText={<ErrorMessage {...{errors, touched}} name="category" />}
|
||||
className={classNames(
|
||||
'form-group--select-list',
|
||||
'form-group--category',
|
||||
Classes.FILL,
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
items={categories}
|
||||
itemRenderer={categoryItem}
|
||||
itemPredicate={filterAccounts}
|
||||
popoverProps={{ minimal: true }}
|
||||
onItemSelect={onItemAccountSelect('category_id')}
|
||||
>
|
||||
<Button
|
||||
fill={true}
|
||||
rightIcon='caret-down'
|
||||
text={getSelectedAccountLabel('category_id', 'Select category')}
|
||||
/>
|
||||
</Select>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label={' '}
|
||||
inline={true}
|
||||
className={'form-group--active'}
|
||||
>
|
||||
<Checkbox
|
||||
inline={true}
|
||||
label={'Active'}
|
||||
defaultChecked={values.active}
|
||||
{...getFieldProps('active')}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={' '}
|
||||
inline={true}
|
||||
className={'form-group--active'}
|
||||
>
|
||||
<Checkbox
|
||||
inline={true}
|
||||
label={'Active'}
|
||||
defaultChecked={values.active}
|
||||
{...getFieldProps('active')}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Col>
|
||||
|
||||
<Col xs={3}>
|
||||
<Dragzone
|
||||
initialFiles={initialAttachmentFiles}
|
||||
onDrop={handleDropFiles}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
hint={'Attachments: Maxiumum size: 20MB'}
|
||||
className={'mt2'} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Row gutterWidth={16} className={'item-form__accounts-section'}>
|
||||
@@ -258,8 +320,7 @@ const ItemForm = ({
|
||||
intent={(errors.sell_account_id && touched.sell_account_id) && Intent.DANGER}
|
||||
helperText={<ErrorMessage {...{errors, touched}} name="sell_account_id" />}
|
||||
className={classNames(
|
||||
'form-group--sell-account',
|
||||
'form-group--select-list',
|
||||
'form-group--sell-account', 'form-group--select-list',
|
||||
Classes.FILL)}
|
||||
>
|
||||
<Select
|
||||
@@ -392,4 +453,5 @@ export default compose(
|
||||
AccountsConnect,
|
||||
ItemsConnect,
|
||||
ItemCategoryConnect,
|
||||
MediaConnect,
|
||||
)(ItemForm);
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
const mapStateToProps = (state, props) => ({
|
||||
views: getResourceViews(state, 'manual_journals'),
|
||||
manualJournals: getManualJournalsItems(state, state.manualJournals.currentViewId),
|
||||
manualJournalsItems: state.manualJournals.items,
|
||||
tableQuery: state.manualJournals.tableQuery,
|
||||
manualJournalsLoading: state.manualJournals.loading,
|
||||
});
|
||||
|
||||
16
client/src/connectors/Media.connect.js
Normal file
16
client/src/connectors/Media.connect.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {
|
||||
submitMedia,
|
||||
deleteMedia,
|
||||
} from 'store/media/media.actions';
|
||||
|
||||
export const mapStateToProps = (state, props) => ({
|
||||
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = (dispatch) => ({
|
||||
requestSubmitMedia: (form, config) => dispatch(submitMedia({ form, config })),
|
||||
requestDeleteMedia: (id) => dispatch(deleteMedia({ id })),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps);
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { FormattedList } from 'react-intl';
|
||||
|
||||
export default function MakeJournalEntriesFooter({
|
||||
formik,
|
||||
formik: { isSubmitting },
|
||||
onSubmitClick,
|
||||
onCancelClick,
|
||||
}) {
|
||||
@@ -14,7 +14,7 @@ export default function MakeJournalEntriesFooter({
|
||||
<div>
|
||||
<div class="form__floating-footer">
|
||||
<Button
|
||||
disabled={formik.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
intent={Intent.PRIMARY}
|
||||
name={'save'}
|
||||
onClick={() => {
|
||||
@@ -24,7 +24,7 @@ export default function MakeJournalEntriesFooter({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={formik.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
intent={Intent.PRIMARY}
|
||||
className={'ml1'}
|
||||
name={'save_and_new'}
|
||||
@@ -35,7 +35,7 @@ export default function MakeJournalEntriesFooter({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={formik.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
className={'button-secondary ml1'}
|
||||
onClick={() => {
|
||||
onSubmitClick({ publish: false, redirect: false });
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, {useMemo, useState, useEffect, useCallback} from 'react';
|
||||
import React, {useMemo, useState, useEffect, useRef, useCallback} from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import {
|
||||
ProgressBar,
|
||||
Classes,
|
||||
Intent,
|
||||
} from '@blueprintjs/core';
|
||||
import MakeJournalEntriesHeader from './MakeJournalEntriesHeader';
|
||||
import MakeJournalEntriesFooter from './MakeJournalEntriesFooter';
|
||||
import MakeJournalEntriesTable from './MakeJournalEntriesTable';
|
||||
@@ -7,12 +12,18 @@ import {useFormik} from "formik";
|
||||
import MakeJournalEntriesConnect from 'connectors/MakeJournalEntries.connect';
|
||||
import AccountsConnect from 'connectors/Accounts.connector';
|
||||
import DashboardConnect from 'connectors/Dashboard.connector';
|
||||
import {compose} from 'utils';
|
||||
import {compose, saveFilesInAsync} from 'utils';
|
||||
import moment from 'moment';
|
||||
import AppToaster from 'components/AppToaster';
|
||||
import {pick} from 'lodash';
|
||||
import Dragzone from 'components/Dragzone';
|
||||
import MediaConnect from 'connectors/Media.connect';
|
||||
import classNames from 'classnames';
|
||||
import ManualJournalsConnect from 'connectors/ManualJournals.connect';
|
||||
import useMedia from 'hooks/useMedia';
|
||||
|
||||
function MakeJournalEntriesForm({
|
||||
requestSubmitMedia,
|
||||
requestMakeJournalEntries,
|
||||
requestEditManualJournal,
|
||||
changePageTitle,
|
||||
@@ -20,7 +31,21 @@ function MakeJournalEntriesForm({
|
||||
editJournal,
|
||||
onFormSubmit,
|
||||
onCancelForm,
|
||||
|
||||
requestDeleteMedia,
|
||||
manualJournalsItems
|
||||
}) {
|
||||
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 (editJournal && editJournal.id) {
|
||||
changePageTitle('Edit Journal');
|
||||
@@ -61,7 +86,7 @@ function MakeJournalEntriesForm({
|
||||
note: '',
|
||||
}), []);
|
||||
|
||||
const initialValues = useMemo(() => ({
|
||||
const defaultInitialValues = useMemo(() => ({
|
||||
journal_number: '',
|
||||
date: moment(new Date()).format('YYYY-MM-DD'),
|
||||
description: '',
|
||||
@@ -74,20 +99,33 @@ function MakeJournalEntriesForm({
|
||||
],
|
||||
}), [defaultEntry]);
|
||||
|
||||
const initialValues = useMemo(() => ({
|
||||
...(editJournal) ? {
|
||||
...pick(editJournal, Object.keys(defaultInitialValues)),
|
||||
entries: editJournal.entries.map((entry) => ({
|
||||
...pick(entry, Object.keys(defaultEntry)),
|
||||
})),
|
||||
} : {
|
||||
...defaultInitialValues,
|
||||
}
|
||||
}), [editJournal, defaultInitialValues, defaultEntry]);
|
||||
|
||||
const initialAttachmentFiles = useMemo(() => {
|
||||
return editJournal && editJournal.media
|
||||
? editJournal.media.map((attach) => ({
|
||||
preview: attach.attachment_file,
|
||||
uploaded: true,
|
||||
metadata: { ...attach },
|
||||
})) : [];
|
||||
}, [editJournal]);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
validationSchema,
|
||||
initialValues: {
|
||||
...(editJournal) ? {
|
||||
...pick(editJournal, Object.keys(initialValues)),
|
||||
entries: editJournal.entries.map((entry) => ({
|
||||
...pick(entry, Object.keys(defaultEntry)),
|
||||
}))
|
||||
} : {
|
||||
...initialValues,
|
||||
}
|
||||
...initialValues,
|
||||
},
|
||||
onSubmit: (values, actions) => {
|
||||
onSubmit: async (values, actions) => {
|
||||
const entries = values.entries.filter((entry) => (
|
||||
(entry.credit || entry.debit)
|
||||
));
|
||||
@@ -99,6 +137,7 @@ function MakeJournalEntriesForm({
|
||||
const totalCredit = getTotal('credit');
|
||||
const totalDebit = getTotal('debit');
|
||||
|
||||
// Validate the total credit should be eqials total debit.
|
||||
if (totalCredit !== totalDebit) {
|
||||
AppToaster.show({
|
||||
message: 'credit_and_debit_not_equal',
|
||||
@@ -108,29 +147,51 @@ function MakeJournalEntriesForm({
|
||||
}
|
||||
const form = { ...values, status: payload.publish, entries };
|
||||
|
||||
if (editJournal && editJournal.id) {
|
||||
requestEditManualJournal(editJournal.id, form)
|
||||
.then((response) => {
|
||||
AppToaster.show({
|
||||
message: 'manual_journal_has_been_edited',
|
||||
});
|
||||
actions.setSubmitting(false);
|
||||
saveInvokeSubmit({ action: 'update', ...payload });
|
||||
}).catch((error) => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
} else {
|
||||
requestMakeJournalEntries(form)
|
||||
.then((response) => {
|
||||
AppToaster.show({
|
||||
message: 'manual_journal_has_been_submit',
|
||||
});
|
||||
actions.setSubmitting(false);
|
||||
saveInvokeSubmit({ action: 'new', ...payload });
|
||||
}).catch((error) => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
}
|
||||
const saveJournal = (mediaIds) => new Promise((resolve, reject) => {
|
||||
const requestForm = { ...form, media_ids: mediaIds };
|
||||
|
||||
if (editJournal && editJournal.id) {
|
||||
requestEditManualJournal(editJournal.id, requestForm)
|
||||
.then((response) => {
|
||||
AppToaster.show({
|
||||
message: 'manual_journal_has_been_edited',
|
||||
});
|
||||
actions.setSubmitting(false);
|
||||
saveInvokeSubmit({ action: 'update', ...payload });
|
||||
clearSavedMediaIds([]);
|
||||
resolve(response);
|
||||
}).catch((error) => {
|
||||
actions.setSubmitting(false);
|
||||
reject(error);
|
||||
});
|
||||
} else {
|
||||
requestMakeJournalEntries(requestForm)
|
||||
.then((response) => {
|
||||
AppToaster.show({
|
||||
message: 'manual_journal_has_been_submit',
|
||||
});
|
||||
actions.setSubmitting(false);
|
||||
saveInvokeSubmit({ action: 'new', ...payload });
|
||||
clearSavedMediaIds();
|
||||
resolve(response);
|
||||
}).catch((error) => {
|
||||
actions.setSubmitting(false);
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all([
|
||||
saveMedia(),
|
||||
deleteMedia(),
|
||||
]).then(([savedMediaResponses]) => {
|
||||
const mediaIds = savedMediaResponses.map(res => res.data.media.id);
|
||||
savedMediaIds.current = mediaIds;
|
||||
|
||||
return savedMediaResponses;
|
||||
}).then(() => {
|
||||
return saveJournal(savedMediaIds.current);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -143,22 +204,45 @@ function MakeJournalEntriesForm({
|
||||
onCancelForm && onCancelForm(payload);
|
||||
}, [onCancelForm]);
|
||||
|
||||
const handleDeleteFile = useCallback((_deletedFiles) => {
|
||||
_deletedFiles.forEach((deletedFile) => {
|
||||
if (deletedFile.uploaded && deletedFile.metadata.id) {
|
||||
setDeletedFiles([
|
||||
...deletedFiles, deletedFile.metadata.id,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}, [setDeletedFiles, deletedFiles]);
|
||||
|
||||
return (
|
||||
<div class="make-journal-entries">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<MakeJournalEntriesHeader formik={formik} />
|
||||
<MakeJournalEntriesTable formik={formik} defaultRow={defaultEntry} />
|
||||
|
||||
<MakeJournalEntriesTable
|
||||
initialValues={initialValues}
|
||||
formik={formik}
|
||||
defaultRow={defaultEntry} />
|
||||
|
||||
<MakeJournalEntriesFooter
|
||||
formik={formik}
|
||||
onSubmitClick={handleSubmitClick}
|
||||
onCancelClick={handleCancelClick} />
|
||||
</form>
|
||||
|
||||
<Dragzone
|
||||
initialFiles={initialAttachmentFiles}
|
||||
onDrop={handleDropFiles}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
hint={'Attachments: Maxiumum size: 20MB'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default compose(
|
||||
ManualJournalsConnect,
|
||||
MakeJournalEntriesConnect,
|
||||
AccountsConnect,
|
||||
DashboardConnect,
|
||||
MediaConnect,
|
||||
)(MakeJournalEntriesForm);
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {useMemo, useCallback} from 'react';
|
||||
import { useParams, useHistory } from 'react-router-dom';
|
||||
import { useAsync } from 'react-use';
|
||||
import useAsync from 'hooks/async';
|
||||
import MakeJournalEntriesForm from './MakeJournalEntriesForm';
|
||||
import DashboardInsider from 'components/Dashboard/DashboardInsider';
|
||||
import DashboardConnect from 'connectors/Dashboard.connector';
|
||||
@@ -22,6 +22,7 @@ function MakeJournalEntriesPage({
|
||||
(id) && fetchManualJournal(id),
|
||||
]);
|
||||
});
|
||||
|
||||
const editJournal = useMemo(() =>
|
||||
getManualJournal(id) || null,
|
||||
[getManualJournal, id]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useState, useMemo, useCallback} from 'react';
|
||||
import React, {useState, useMemo, useEffect, useCallback} from 'react';
|
||||
import {
|
||||
Button,
|
||||
Intent,
|
||||
@@ -74,34 +74,36 @@ const NoteCellRenderer = (chainedComponent) => (props) => {
|
||||
* Make journal entries table component.
|
||||
*/
|
||||
function MakeJournalEntriesTable({
|
||||
formik,
|
||||
formik: { errors, values, setFieldValue },
|
||||
accounts,
|
||||
onClickRemoveRow,
|
||||
onClickAddNewRow,
|
||||
defaultRow,
|
||||
initialValues,
|
||||
}) {
|
||||
const [rows, setRow] = useState([
|
||||
...formik.values.entries.map((e) => ({ ...e, rowType: 'editor'})),
|
||||
defaultRow,
|
||||
defaultRow,
|
||||
]);
|
||||
const [rows, setRow] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setRow([
|
||||
...initialValues.entries.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 { ...rows[rowIndex], [columnId]: value };
|
||||
}
|
||||
return { ...row };
|
||||
});
|
||||
setRow(newRows);
|
||||
formik.setFieldValue('entries', newRows.map(row => ({
|
||||
setFieldValue('entries', newRows.map(row => ({
|
||||
...omit(row, ['rowType']),
|
||||
})));
|
||||
}, [rows, formik]);
|
||||
}, [rows, setFieldValue]);
|
||||
|
||||
// Handles click remove datatable row.
|
||||
const handleRemoveRow = useCallback((rowIndex) => {
|
||||
@@ -109,12 +111,12 @@ function MakeJournalEntriesTable({
|
||||
const newRows = rows.filter((row, index) => index !== removeIndex);
|
||||
|
||||
setRow([ ...newRows ]);
|
||||
formik.setFieldValue('entries', newRows
|
||||
setFieldValue('entries', newRows
|
||||
.filter(row => row.rowType === 'editor')
|
||||
.map(row => ({ ...omit(row, ['rowType']) })
|
||||
));
|
||||
onClickRemoveRow && onClickRemoveRow(removeIndex);
|
||||
}, [rows, formik, onClickRemoveRow]);
|
||||
}, [rows, setFieldValue, onClickRemoveRow]);
|
||||
|
||||
// Memorized data table columns.
|
||||
const columns = useMemo(() => [
|
||||
@@ -196,7 +198,7 @@ function MakeJournalEntriesTable({
|
||||
rowClassNames={rowClassNames}
|
||||
payload={{
|
||||
accounts,
|
||||
errors: formik.errors.entries || [],
|
||||
errors: errors.entries || [],
|
||||
updateData: handleUpdateData,
|
||||
removeRow: handleRemoveRow,
|
||||
}}/>
|
||||
|
||||
@@ -34,7 +34,7 @@ function BalanceSheet({
|
||||
await Promise.all([
|
||||
fetchBalanceSheet({ ...query }),
|
||||
]);
|
||||
});
|
||||
}, false);
|
||||
|
||||
// Handle fetch the data of balance sheet.
|
||||
const handleFetchData = useCallback(() => { fetchHook.execute(); }, [fetchHook]);
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
compose,
|
||||
defaultExpanderReducer,
|
||||
} from 'utils';
|
||||
import SettingsConnect from 'connectors/Settings.connect';
|
||||
|
||||
function BalanceSheetTable({
|
||||
companyName,
|
||||
organizationSettings,
|
||||
balanceSheetAccounts,
|
||||
balanceSheetColumns,
|
||||
balanceSheetQuery,
|
||||
@@ -110,7 +111,7 @@ function BalanceSheetTable({
|
||||
|
||||
return (
|
||||
<FinancialSheet
|
||||
companyName={companyName}
|
||||
companyName={organizationSettings.name}
|
||||
sheetType={'Balance Sheet'}
|
||||
fromDate={balanceSheetQuery.from_date}
|
||||
toDate={balanceSheetQuery.to_date}
|
||||
@@ -122,8 +123,9 @@ function BalanceSheetTable({
|
||||
columns={columns}
|
||||
data={balanceSheetAccounts}
|
||||
onFetchData={handleFetchData}
|
||||
expanded={expandedRows}
|
||||
expandSubRows={true}
|
||||
expanded={expandedRows} />
|
||||
noInitialFetch={true} />
|
||||
</FinancialSheet>
|
||||
);
|
||||
}
|
||||
@@ -131,4 +133,5 @@ function BalanceSheetTable({
|
||||
export default compose(
|
||||
BalanceSheetConnect,
|
||||
BalanceSheetTableConnect,
|
||||
SettingsConnect,
|
||||
)(BalanceSheetTable);
|
||||
@@ -89,7 +89,8 @@ function JournalSheetTable({
|
||||
data={data}
|
||||
onFetchData={handleFetchData}
|
||||
noResults={"This report does not contain any data."}
|
||||
expanded={expandedRows} />
|
||||
expanded={expandedRows}
|
||||
noInitialFetch={true} />
|
||||
</FinancialSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,8 @@ function ProfitLossSheetTable({
|
||||
data={profitLossTableRows}
|
||||
onFetchData={handleFetchData}
|
||||
expanded={expandedRows}
|
||||
rowClassNames={rowClassNames} />
|
||||
rowClassNames={rowClassNames}
|
||||
noInitialFetch={true} />
|
||||
</FinancialSheet>
|
||||
);
|
||||
}
|
||||
|
||||
59
client/src/hooks/useMedia.js
Normal file
59
client/src/hooks/useMedia.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useState, useRef, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
ProgressBar,
|
||||
Classes,
|
||||
Intent,
|
||||
} from '@blueprintjs/core';
|
||||
import classNames from 'classnames';
|
||||
import AppToaster from 'components/AppToaster';
|
||||
import { saveFilesInAsync } from 'utils';
|
||||
|
||||
const useMedia = ({ saveCallback, deleteCallback }) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [deletedFiles, setDeletedFiles] = useState([]);
|
||||
const toastKey = useRef(0);
|
||||
|
||||
const openProgressToast = useCallback((amount) => ({
|
||||
message: (
|
||||
<ProgressBar
|
||||
className={classNames("toast-progress", {
|
||||
[Classes.PROGRESS_NO_STRIPES]: amount >= 100,
|
||||
})}
|
||||
intent={amount < 100 ? Intent.PRIMARY : Intent.SUCCESS}
|
||||
value={amount / 100}
|
||||
/>
|
||||
),
|
||||
}), []);
|
||||
|
||||
const saveMedia = useCallback(() => {
|
||||
const notUploadedFiles = files.filter((file) => file.uploaded === false);
|
||||
|
||||
if (notUploadedFiles.length > 0) {
|
||||
toastKey.current = AppToaster.show(openProgressToast(0));
|
||||
|
||||
const saveAction = (formData, attachment, progressCallback) => {
|
||||
return saveCallback(formData, {
|
||||
onUploadProgress: (progress) => { progressCallback(progress); }
|
||||
}).then((res) => {
|
||||
attachment.uploaded = true;
|
||||
return res;
|
||||
});
|
||||
};
|
||||
return saveFilesInAsync(notUploadedFiles, saveAction).onProgress((progress) => {
|
||||
if (progress > 0) {
|
||||
AppToaster.show(openProgressToast(progress * 100), toastKey.current);
|
||||
}
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}, [files, openProgressToast, saveCallback]);
|
||||
|
||||
const deleteMedia = useCallback(() => {
|
||||
return deletedFiles.length > 0
|
||||
? deleteCallback(deletedFiles) : Promise.resolve();
|
||||
}, [deletedFiles, deleteCallback]);
|
||||
|
||||
return { files, setFiles, saveMedia, deletedFiles, setDeletedFiles, deleteMedia };
|
||||
}
|
||||
|
||||
export default useMedia;
|
||||
@@ -6,8 +6,8 @@ export default {
|
||||
return axios.get(`/api/${resource}`, params);
|
||||
},
|
||||
|
||||
post(resource, params) {
|
||||
return axios.post(`/api/${resource}`, params);
|
||||
post(resource, params, config) {
|
||||
return axios.post(`/api/${resource}`, params, config);
|
||||
},
|
||||
|
||||
update(resource, slug, params) {
|
||||
@@ -21,4 +21,4 @@ export default {
|
||||
delete(resource, params) {
|
||||
return axios.delete(`/api/${resource}`, params);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -11,27 +11,32 @@ const initialState = {
|
||||
tableQuery: {},
|
||||
};
|
||||
|
||||
const defaultJournal = {
|
||||
entries: [],
|
||||
};
|
||||
|
||||
const reducer = createReducer(initialState, {
|
||||
|
||||
[t.MANUAL_JOURNAL_SET]: (state, action) => {
|
||||
const { id, manualJournal } = action.payload;
|
||||
state.items[id] = manualJournal;
|
||||
state.items[id] = { ...defaultJournal, ...manualJournal };
|
||||
},
|
||||
|
||||
[t.MANUAL_JOURNAL_PUBLISH]: (state, action) => {
|
||||
const { id } = action.payload;
|
||||
const item = state.items[id] || {};
|
||||
|
||||
state.items[id] = {
|
||||
...item, status: 1,
|
||||
};
|
||||
state.items[id] = { ...item, status: 1 };
|
||||
},
|
||||
|
||||
[t.MANUAL_JOURNALS_ITEMS_SET]: (state, action) => {
|
||||
const _manual_journals = {};
|
||||
|
||||
action.manual_journals.forEach((manual_journal) => {
|
||||
_manual_journals[manual_journal.id] = manual_journal;
|
||||
_manual_journals[manual_journal.id] = {
|
||||
...defaultJournal,
|
||||
...manual_journal,
|
||||
};
|
||||
});
|
||||
state.items = {
|
||||
...state.items,
|
||||
|
||||
13
client/src/store/media/media.actions.js
Normal file
13
client/src/store/media/media.actions.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import ApiService from "services/ApiService"
|
||||
|
||||
export const submitMedia = ({ form, config }) => {
|
||||
return (dispatch) => {
|
||||
return ApiService.post('media/upload', form, config);
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteMedia = ({ id }) => {
|
||||
return (dispatch) => {
|
||||
return ApiService.delete('media', { params: { id } });
|
||||
}
|
||||
};
|
||||
0
client/src/store/media/media.reducers.js
Normal file
0
client/src/store/media/media.reducers.js
Normal file
0
client/src/store/media/media.types.js
Normal file
0
client/src/store/media/media.types.js
Normal file
@@ -3,7 +3,9 @@ import t from 'store/types';
|
||||
import { optionsArrayToMap } from 'utils';
|
||||
const initialState = {
|
||||
data: {
|
||||
organization: {},
|
||||
organization: {
|
||||
name: 'Bigcapital, Limited Liabilities',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ $pt-font-family: Noto Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
|
||||
@import 'components/data-table';
|
||||
@import 'components/dialog';
|
||||
@import 'components/custom-scrollbar';
|
||||
@import 'components/dragzone';
|
||||
|
||||
// Pages
|
||||
@import 'pages/dashboard';
|
||||
|
||||
74
client/src/style/components/dragzone.scss
Normal file
74
client/src/style/components/dragzone.scss
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
.dropzone{
|
||||
flex: 1 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 18px;
|
||||
border-width: 1px;
|
||||
border-color: #AFAFAF;
|
||||
border-style: dashed;
|
||||
color: #999;
|
||||
outline: none;
|
||||
transition: border .24s ease-in-out;
|
||||
font-size: 14px;
|
||||
|
||||
p{
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone-thumbs{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.dropzone-thumb{
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #eaeaea;
|
||||
margin-bottom: 8px;
|
||||
margin-right: 8px;
|
||||
width: 100px;
|
||||
padding: 2px;
|
||||
|
||||
img{
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button{
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
color: #fff;
|
||||
background: #db3737;
|
||||
border: 0;
|
||||
border-radius: 15px;
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
visibility: hidden;
|
||||
|
||||
.bp3-icon{
|
||||
position: relative;
|
||||
top: -3px;
|
||||
left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover button{
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone-hint{
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
color: #777;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
.make-journal-entries{
|
||||
padding-bottom: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header{
|
||||
padding: 25px 27px 20px;
|
||||
@@ -17,7 +19,7 @@
|
||||
}
|
||||
|
||||
&__table{
|
||||
padding: 15px;
|
||||
padding: 15px 15px 0;
|
||||
|
||||
.bp3-form-group{
|
||||
margin-bottom: 0;
|
||||
@@ -154,4 +156,16 @@
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.dropzone-container{
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.dropzone{
|
||||
|
||||
width: 300px;
|
||||
height: 75px;
|
||||
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
import Currency from 'js-money/lib/currency';
|
||||
import PProgress from 'p-progress';
|
||||
import accounting from 'accounting';
|
||||
|
||||
|
||||
@@ -151,4 +152,22 @@ export const checkRequiredProperties = (obj, properties) => {
|
||||
const value = obj[prop];
|
||||
return (value === '' || value === null || value === undefined);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const saveFilesInAsync = (files, actionCb, extraTasks) => {
|
||||
const opers = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', file.file);
|
||||
const oper = new PProgress((resolve, reject, progress) => {
|
||||
actionCb(formData, file, (requestProgress) => {
|
||||
progress(requestProgress);
|
||||
})
|
||||
.then((data) => { resolve(data); })
|
||||
.catch(error => { reject(error); })
|
||||
});
|
||||
opers.push(oper);
|
||||
});
|
||||
return PProgress.all(opers);
|
||||
}
|
||||
|
||||
3
server/.gitignore
vendored
3
server/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
/node_modules/
|
||||
/.env
|
||||
/.env.test
|
||||
/.env.test
|
||||
/storage
|
||||
@@ -10,6 +10,8 @@ exports.up = function (knex) {
|
||||
table.integer('role_id').unique();
|
||||
table.string('language');
|
||||
table.date('last_login_at');
|
||||
|
||||
table.date('invite_accepted_at');
|
||||
table.timestamps();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.createTable('media_links', table => {
|
||||
table.increments();
|
||||
table.string('model_name');
|
||||
table.integer('media_id').unsigned();
|
||||
table.integer('model_id').unsigned();
|
||||
})
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.dropTableIfExists('media_links');
|
||||
};
|
||||
@@ -82,7 +82,7 @@ export default {
|
||||
const filter = {
|
||||
filter_roles: [],
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
page_size: 999,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
@@ -90,8 +90,6 @@ export default {
|
||||
}
|
||||
const { Resource, View, ManualJournal } = req.models;
|
||||
|
||||
console.log(req.models);
|
||||
|
||||
const errorReasons = [];
|
||||
const manualJournalsResource = await Resource.query()
|
||||
.where('name', 'manual_journals')
|
||||
@@ -185,6 +183,8 @@ export default {
|
||||
check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(),
|
||||
check('entries.*.account_id').isNumeric().toInt(),
|
||||
check('entries.*.note').optional(),
|
||||
check('media_ids').optional().isArray(),
|
||||
check('media_ids.*').exists().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
@@ -198,9 +198,10 @@ export default {
|
||||
date: new Date(),
|
||||
transaction_type: 'journal',
|
||||
reference: '',
|
||||
media_ids: [],
|
||||
...req.body,
|
||||
};
|
||||
const { ManualJournal, Account } = req.models;
|
||||
const { ManualJournal, Account, Media, MediaLink } = req.models;
|
||||
|
||||
let totalCredit = 0;
|
||||
let totalDebit = 0;
|
||||
@@ -233,6 +234,14 @@ export default {
|
||||
|
||||
const storedAccountsIds = accounts.map((account) => account.id);
|
||||
|
||||
if (form.media_ids.length > 0) {
|
||||
const storedMedia = await Media.query().whereIn('id', form.media_ids);
|
||||
const notFoundMedia = difference(form.media_ids, storedMedia.map((m) => m.id));
|
||||
|
||||
if (notFoundMedia.length > 0) {
|
||||
errorReasons.push({ type: 'MEDIA.IDS.NOT.FOUND', code: 400, ids: notFoundMedia });
|
||||
}
|
||||
}
|
||||
if (difference(accountsIds, storedAccountsIds).length > 0) {
|
||||
errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 });
|
||||
}
|
||||
@@ -279,8 +288,22 @@ export default {
|
||||
journalPoster.credit(jouranlEntry);
|
||||
}
|
||||
});
|
||||
|
||||
// Save linked media to the journal model.
|
||||
const bulkSaveMediaLink = [];
|
||||
|
||||
form.media_ids.forEach((mediaId) => {
|
||||
const oper = MediaLink.query().insert({
|
||||
model_name: 'Journal',
|
||||
model_id: manualJournal.id,
|
||||
media_id: mediaId,
|
||||
});
|
||||
bulkSaveMediaLink.push(oper);
|
||||
});
|
||||
|
||||
// Saves the journal entries and accounts balance changes.
|
||||
await Promise.all([
|
||||
...bulkSaveMediaLink,
|
||||
journalPoster.saveEntries(),
|
||||
(form.status) && journalPoster.saveBalance(),
|
||||
]);
|
||||
@@ -313,6 +336,9 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Edit the given manual journal.
|
||||
*/
|
||||
editManualJournal: {
|
||||
validation: [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
@@ -326,6 +352,8 @@ export default {
|
||||
check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(),
|
||||
check('entries.*.account_id').isNumeric().toInt(),
|
||||
check('entries.*.note').optional(),
|
||||
check('media_ids').optional().isArray(),
|
||||
check('media_ids.*').isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
@@ -339,14 +367,17 @@ export default {
|
||||
date: new Date(),
|
||||
transaction_type: 'journal',
|
||||
reference: '',
|
||||
media_ids: [],
|
||||
...req.body,
|
||||
};
|
||||
const { id } = req.params;
|
||||
const {
|
||||
ManualJournal, AccountTransaction, Account,
|
||||
ManualJournal, AccountTransaction, Account, Media, MediaLink,
|
||||
} = req.models;
|
||||
|
||||
const manualJournal = await ManualJournal.query().where('id', id).first();
|
||||
const manualJournal = await ManualJournal.query()
|
||||
.where('id', id)
|
||||
.withGraphFetched('media').first();
|
||||
|
||||
if (!manualJournal) {
|
||||
return res.status(4040).send({
|
||||
@@ -395,6 +426,16 @@ export default {
|
||||
if (difference(accountsIds, storedAccountsIds).length > 0) {
|
||||
errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 });
|
||||
}
|
||||
|
||||
// Validate if media ids was not already exists on the storage.
|
||||
if (form.media_ids.length > 0) {
|
||||
const storedMedia = await Media.query().whereIn('id', form.media_ids);
|
||||
const notFoundMedia = difference(form.media_ids, storedMedia.map((m) => m.id));
|
||||
|
||||
if (notFoundMedia.length > 0) {
|
||||
errorReasons.push({ type: 'MEDIA.IDS.NOT.FOUND', code: 400, ids: notFoundMedia });
|
||||
}
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
@@ -439,7 +480,23 @@ export default {
|
||||
journal.credit(jouranlEntry);
|
||||
}
|
||||
});
|
||||
|
||||
// Save links of new inserted media that associated to the journal model.
|
||||
const journalMediaIds = manualJournal.media.map((m) => m.id);
|
||||
const newInsertedMedia = difference(form.media_ids, journalMediaIds);
|
||||
const bulkSaveMediaLink = [];
|
||||
|
||||
newInsertedMedia.forEach((mediaId) => {
|
||||
const oper = MediaLink.query().insert({
|
||||
model_name: 'Journal',
|
||||
model_id: manualJournal.id,
|
||||
media_id: mediaId,
|
||||
});
|
||||
bulkSaveMediaLink.push(oper);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
...bulkSaveMediaLink,
|
||||
journal.deleteEntries(),
|
||||
journal.saveEntries(),
|
||||
journal.saveBalance(),
|
||||
@@ -524,7 +581,9 @@ export default {
|
||||
|
||||
const { id } = req.params;
|
||||
const manualJournal = await ManualJournal.query()
|
||||
.where('id', id).first();
|
||||
.where('id', id)
|
||||
.withGraphFetched('media')
|
||||
.first();
|
||||
|
||||
if (!manualJournal) {
|
||||
return res.status(404).send({
|
||||
@@ -564,7 +623,9 @@ export default {
|
||||
}
|
||||
const { id } = req.params;
|
||||
const {
|
||||
ManualJournal, AccountTransaction,
|
||||
ManualJournal,
|
||||
AccountTransaction,
|
||||
MediaLink,
|
||||
} = req.models;
|
||||
const manualJournal = await ManualJournal.query()
|
||||
.where('id', id).first();
|
||||
@@ -583,6 +644,11 @@ export default {
|
||||
journal.loadEntries(transactions);
|
||||
journal.removeEntries();
|
||||
|
||||
await MediaLink.query()
|
||||
.where('model_name', 'Journal')
|
||||
.where('model_id', manualJournal.id)
|
||||
.delete();
|
||||
|
||||
await ManualJournal.query()
|
||||
.where('id', manualJournal.id)
|
||||
.delete();
|
||||
@@ -678,7 +744,7 @@ export default {
|
||||
});
|
||||
}
|
||||
const filter = { ...req.query };
|
||||
const { ManualJournal, AccountTransaction } = req.models;
|
||||
const { ManualJournal, AccountTransaction, MediaLink } = req.models;
|
||||
|
||||
const manualJournals = await ManualJournal.query()
|
||||
.whereIn('id', filter.ids);
|
||||
@@ -699,6 +765,11 @@ export default {
|
||||
journal.loadEntries(transactions);
|
||||
journal.removeEntries();
|
||||
|
||||
await MediaLink.query()
|
||||
.where('model_name', 'Journal')
|
||||
.whereIn('model_id', filter.ids)
|
||||
.delete();
|
||||
|
||||
await ManualJournal.query()
|
||||
.whereIn('id', filter.ids).delete();
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ export default {
|
||||
check('custom_fields.*.value').exists(),
|
||||
|
||||
check('note').optional(),
|
||||
|
||||
check('media_ids').optional().isArray(),
|
||||
check('media_ids.*').exists().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
@@ -119,7 +119,7 @@ export default {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { Media } = req.models;
|
||||
const { Media, MediaLink } = req.models;
|
||||
const { id } = req.params;
|
||||
const media = await Media.query().where('id', id).first();
|
||||
|
||||
@@ -137,6 +137,8 @@ export default {
|
||||
} catch (error) {
|
||||
Logger.log('error', 'Delete item attachment file delete failed.', { error });
|
||||
}
|
||||
|
||||
await MediaLink.query().where('media_id', media.id).delete();
|
||||
await Media.query().where('id', media.id).delete();
|
||||
|
||||
return res.status(200).send();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Model } from 'objection';
|
||||
import TenantModel from '@/models/TenantModel';
|
||||
|
||||
export default class ManualJournal extends TenantModel {
|
||||
@@ -7,4 +8,26 @@ export default class ManualJournal extends TenantModel {
|
||||
static get tableName() {
|
||||
return 'manual_journals';
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship mapping.
|
||||
*/
|
||||
static get relationMappings() {
|
||||
const Media = require('@/models/Media');
|
||||
|
||||
return {
|
||||
media: {
|
||||
relation: Model.ManyToManyRelation,
|
||||
modelClass: this.relationBindKnex(Media.default),
|
||||
join: {
|
||||
from: 'manual_journals.id',
|
||||
through: {
|
||||
from: 'media_links.model_id',
|
||||
to: 'media_links.media_id',
|
||||
},
|
||||
to: 'media.id',
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
10
server/src/models/MediaLink.js
Normal file
10
server/src/models/MediaLink.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import TenantModel from '@/models/TenantModel';
|
||||
|
||||
export default class MediaLink extends TenantModel {
|
||||
/**
|
||||
* Table name
|
||||
*/
|
||||
static get tableName() {
|
||||
return 'media_links';
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ export default class JournalPoster {
|
||||
|
||||
async deleteEntries() {
|
||||
if (this.deletedEntriesIds.length > 0) {
|
||||
await AccountTransaction.query()
|
||||
await AccountTransaction.tenant().query()
|
||||
.whereIn('id', this.deletedEntriesIds)
|
||||
.delete();
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ exports.up = function (knex) {
|
||||
table.boolean('active');
|
||||
table.integer('role_id').unique();
|
||||
table.string('language');
|
||||
table.date('last_login_at');
|
||||
|
||||
table.integer('tenant_id').unsigned();
|
||||
|
||||
table.date('last_login_at');
|
||||
table.timestamps();
|
||||
}).then(() => {
|
||||
// knex.seed.run({
|
||||
|
||||
Reference in New Issue
Block a user