feat: Attachment files system.

This commit is contained in:
Ahmed Bouhuolia
2020-05-04 05:11:44 +02:00
parent a807cf6bb8
commit 7f06e3781c
35 changed files with 757 additions and 179 deletions

11
client/.flowconfig Normal file
View File

@@ -0,0 +1,11 @@
[ignore]
[include]
[libs]
[lints]
[options]
[strict]

View File

@@ -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"
},

View 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>
);
}

View File

@@ -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);

View File

@@ -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,
});

View 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);

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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]);

View File

@@ -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,
}}/>

View File

@@ -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]);

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -126,7 +126,8 @@ function ProfitLossSheetTable({
data={profitLossTableRows}
onFetchData={handleFetchData}
expanded={expandedRows}
rowClassNames={rowClassNames} />
rowClassNames={rowClassNames}
noInitialFetch={true} />
</FinancialSheet>
);
}

View 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;

View File

@@ -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);
}
};
};

View File

@@ -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,

View 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 } });
}
};

View File

View File

View File

@@ -3,7 +3,9 @@ import t from 'store/types';
import { optionsArrayToMap } from 'utils';
const initialState = {
data: {
organization: {},
organization: {
name: 'Bigcapital, Limited Liabilities',
},
},
};

View File

@@ -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';

View 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;
}

View File

@@ -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;
}
}

View File

@@ -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
View File

@@ -1,3 +1,4 @@
/node_modules/
/.env
/.env.test
/.env.test
/storage

View File

@@ -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();
});
};

View File

@@ -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');
};

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();

View File

@@ -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',
}
}
};
}
}

View File

@@ -0,0 +1,10 @@
import TenantModel from '@/models/TenantModel';
export default class MediaLink extends TenantModel {
/**
* Table name
*/
static get tableName() {
return 'media_links';
}
}

View File

@@ -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();
}

View File

@@ -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({