mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-19 06:10:31 +00:00
feat: aggregate rows on import feature
This commit is contained in:
@@ -72,6 +72,10 @@ function ManualJournalActionsBar({
|
||||
const handleRefreshBtnClick = () => {
|
||||
refresh();
|
||||
};
|
||||
// Handle import button click.
|
||||
const handleImportBtnClick = () => {
|
||||
history.push('/manual-journals/import');
|
||||
}
|
||||
|
||||
// Handle table row size change.
|
||||
const handleTableRowSizeChange = (size) => {
|
||||
@@ -130,6 +134,7 @@ function ManualJournalActionsBar({
|
||||
className={Classes.MINIMAL}
|
||||
icon={<Icon icon="file-import-16" iconSize={16} />}
|
||||
text={<T id={'import'} />}
|
||||
onClick={handleImportBtnClick}
|
||||
/>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @ts-nocheck
|
||||
import { DashboardInsider } from '@/components';
|
||||
import { ImportView } from '../Import/ImportView';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
export default function ManualJournalsImport() {
|
||||
const history = useHistory();
|
||||
|
||||
const handleCancelBtnClick = () => {
|
||||
history.push('/manual-journals');
|
||||
};
|
||||
const handleImportSuccess = () => {
|
||||
history.push('/manual-journals');
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardInsider name={'import-manual-journals'}>
|
||||
<ImportView
|
||||
resource={'manual-journals'}
|
||||
onCancelClick={handleCancelBtnClick}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
/>
|
||||
</DashboardInsider>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import clsx from 'classnames';
|
||||
import { Button, Intent, Position } from '@blueprintjs/core';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { FSelect, Group, Hint } from '@/components';
|
||||
import { Box, FSelect, Group, Hint } from '@/components';
|
||||
import { ImportFileMappingForm } from './ImportFileMappingForm';
|
||||
import { EntityColumn, useImportFileContext } from './ImportFileProvider';
|
||||
import { EntityColumnField, useImportFileContext } from './ImportFileProvider';
|
||||
import { CLASSES } from '@/constants';
|
||||
import { ImportFileContainer } from './ImportFileContainer';
|
||||
import { ImportStepperStep } from './_types';
|
||||
import { ImportFileMapBootProvider } from './ImportFileMappingBoot';
|
||||
import styles from './ImportFileMapping.module.scss';
|
||||
import { getFieldKey } from './_utils';
|
||||
|
||||
export function ImportFileMapping() {
|
||||
const { importId } = useImportFileContext();
|
||||
const { importId, entityColumns } = useImportFileContext();
|
||||
|
||||
return (
|
||||
<ImportFileMapBootProvider importId={importId}>
|
||||
@@ -23,56 +24,98 @@ export function ImportFileMapping() {
|
||||
Bigcapital fields.
|
||||
</p>
|
||||
|
||||
<table className={clsx('bp4-html-table', styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.label}>Bigcapital Fields</th>
|
||||
<th className={styles.field}>Sheet Column Headers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ImportFileMappingFields />
|
||||
</tbody>
|
||||
</table>
|
||||
{entityColumns.map((entityColumn, index) => (
|
||||
<ImportFileMappingGroup
|
||||
groupKey={entityColumn.groupKey}
|
||||
groupName={entityColumn.groupName}
|
||||
fields={entityColumn.fields}
|
||||
/>
|
||||
))}
|
||||
</ImportFileContainer>
|
||||
|
||||
<ImportFileMappingFloatingActions />
|
||||
</ImportFileMappingForm>
|
||||
</ImportFileMapBootProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportFileMappingFields() {
|
||||
const { entityColumns, sheetColumns } = useImportFileContext();
|
||||
interface ImportFileMappingGroupProps {
|
||||
groupKey: string;
|
||||
groupName: string;
|
||||
fields: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping fields group
|
||||
* @returns {React.ReactNode}
|
||||
*/
|
||||
function ImportFileMappingGroup({
|
||||
groupKey,
|
||||
groupName,
|
||||
fields,
|
||||
}: ImportFileMappingGroupProps) {
|
||||
return (
|
||||
<Box>
|
||||
{groupName && <h3>{groupName}</h3>}
|
||||
|
||||
<table className={clsx('bp4-html-table', styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.label}>Bigcapital Fields</th>
|
||||
<th className={styles.field}>Sheet Column Headers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ImportFileMappingFields fields={fields} />
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImportFileMappingFieldsProps {
|
||||
fields: EntityColumnField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Import mapping fields.
|
||||
* @returns {React.ReactNode}
|
||||
*/
|
||||
function ImportFileMappingFields({ fields }: ImportFileMappingFieldsProps) {
|
||||
const { sheetColumns } = useImportFileContext();
|
||||
|
||||
const items = useMemo(
|
||||
() => sheetColumns.map((column) => ({ value: column, text: column })),
|
||||
[sheetColumns],
|
||||
);
|
||||
const columnMapper = (column: EntityColumn, index: number) => (
|
||||
<tr key={index}>
|
||||
<td className={styles.label}>
|
||||
{column.name}{' '}
|
||||
{column.required && <span className={styles.requiredSign}>*</span>}
|
||||
</td>
|
||||
<td className={styles.field}>
|
||||
<Group spacing={4}>
|
||||
<FSelect
|
||||
name={column.key}
|
||||
items={items}
|
||||
popoverProps={{ minimal: true }}
|
||||
minimal={true}
|
||||
fill={true}
|
||||
/>
|
||||
{column.hint && (
|
||||
<Hint content={column.hint} position={Position.BOTTOM} />
|
||||
)}
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
const columnMapper = useCallback(
|
||||
(column: EntityColumnField, index: number) => (
|
||||
<tr key={index}>
|
||||
<td className={styles.label}>
|
||||
{column.name}{' '}
|
||||
{column.required && <span className={styles.requiredSign}>*</span>}
|
||||
</td>
|
||||
<td className={styles.field}>
|
||||
<Group spacing={4}>
|
||||
<FSelect
|
||||
name={getFieldKey(column.key, column.group)}
|
||||
items={items}
|
||||
popoverProps={{ minimal: true }}
|
||||
minimal={true}
|
||||
fill={true}
|
||||
/>
|
||||
{column.hint && (
|
||||
<Hint content={column.hint} position={Position.BOTTOM} />
|
||||
)}
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
[items],
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => fields.map(columnMapper),
|
||||
[columnMapper, fields],
|
||||
);
|
||||
const columns = entityColumns.map(columnMapper);
|
||||
|
||||
return <>{columns}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,12 @@ import { Intent } from '@blueprintjs/core';
|
||||
import { useImportFileMapping } from '@/hooks/query/import';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { useImportFileContext } from './ImportFileProvider';
|
||||
import { useMemo } from 'react';
|
||||
import { isEmpty, lowerCase } from 'lodash';
|
||||
import { AppToaster } from '@/components';
|
||||
import { useImportFileMapBootContext } from './ImportFileMappingBoot';
|
||||
import { transformToForm } from '@/utils';
|
||||
|
||||
interface ImportFileMappingFormProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
type ImportFileMappingFormValues = Record<string, string | null>;
|
||||
import { ImportFileMappingFormProps } from './_types';
|
||||
import {
|
||||
transformValueToReq,
|
||||
useImportFileMappingInitialValues,
|
||||
} from './_utils';
|
||||
|
||||
export function ImportFileMappingForm({
|
||||
children,
|
||||
@@ -52,50 +47,3 @@ export function ImportFileMappingForm({
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
const transformValueToReq = (value: ImportFileMappingFormValues) => {
|
||||
const mapping = Object.keys(value)
|
||||
.filter((key) => !isEmpty(value[key]))
|
||||
.map((key) => ({ from: value[key], to: key }));
|
||||
return { mapping };
|
||||
};
|
||||
|
||||
const transformResToFormValues = (value: { from: string; to: string }[]) => {
|
||||
return value?.reduce((acc, map) => {
|
||||
acc[map.to] = map.from;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const useImportFileMappingInitialValues = () => {
|
||||
const { importFile } = useImportFileMapBootContext();
|
||||
const { entityColumns, sheetColumns } = useImportFileContext();
|
||||
|
||||
const initialResValues = useMemo(
|
||||
() => transformResToFormValues(importFile?.map || []),
|
||||
[importFile?.map],
|
||||
);
|
||||
|
||||
const initialValues = useMemo(
|
||||
() =>
|
||||
entityColumns.reduce((acc, { key, name }) => {
|
||||
const _name = lowerCase(name);
|
||||
const _matched = sheetColumns.find(
|
||||
(column) => lowerCase(column) === _name,
|
||||
);
|
||||
// Match the default column name the same field name
|
||||
// if matched one of sheet columns has the same field name.
|
||||
acc[key] = _matched ? _matched : '';
|
||||
return acc;
|
||||
}, {}),
|
||||
[entityColumns, sheetColumns],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
...transformToForm(initialResValues, initialValues),
|
||||
...initialValues,
|
||||
}),
|
||||
[initialValues, initialResValues],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,12 +7,19 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export type EntityColumn = {
|
||||
export type EntityColumnField = {
|
||||
key: string;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
group?: string;
|
||||
};
|
||||
|
||||
export interface EntityColumn {
|
||||
groupKey: string;
|
||||
groupName: string;
|
||||
fields: EntityColumnField[];
|
||||
}
|
||||
export type SheetColumn = string;
|
||||
export type SheetMap = { from: string; to: string };
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as Yup from 'yup';
|
||||
import { useImportFileContext } from './ImportFileProvider';
|
||||
import { ImportAlert, ImportStepperStep } from './_types';
|
||||
import { useAlertsManager } from './AlertsManager';
|
||||
import { transformToCamelCase } from '@/utils';
|
||||
|
||||
const initialValues = {
|
||||
file: null,
|
||||
@@ -55,9 +56,11 @@ export function ImportFileUploadForm({
|
||||
|
||||
uploadImportFile(formData)
|
||||
.then(({ data }) => {
|
||||
setImportId(data.import.import_id);
|
||||
setSheetColumns(data.sheet_columns);
|
||||
setEntityColumns(data.resource_columns);
|
||||
const _data = transformToCamelCase(data);
|
||||
|
||||
setImportId(_data.import.importId);
|
||||
setSheetColumns(_data.sheetColumns);
|
||||
setEntityColumns(_data.resourceColumns);
|
||||
setStep(ImportStepperStep.Mapping);
|
||||
setSubmitting(false);
|
||||
})
|
||||
|
||||
@@ -5,5 +5,11 @@ export enum ImportStepperStep {
|
||||
}
|
||||
|
||||
export enum ImportAlert {
|
||||
IMPORTED_SHEET_EMPTY = 'IMPORTED_SHEET_EMPTY'
|
||||
}
|
||||
IMPORTED_SHEET_EMPTY = 'IMPORTED_SHEET_EMPTY',
|
||||
}
|
||||
|
||||
export interface ImportFileMappingFormProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export type ImportFileMappingFormValues = Record<string, string | null>;
|
||||
|
||||
87
packages/webapp/src/containers/Import/_utils.ts
Normal file
87
packages/webapp/src/containers/Import/_utils.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useMemo } from 'react';
|
||||
import { chain, isEmpty, lowerCase, head, last, set } from 'lodash';
|
||||
import { useImportFileContext } from './ImportFileProvider';
|
||||
import { useImportFileMapBootContext } from './ImportFileMappingBoot';
|
||||
import { deepdash, transformToForm } from '@/utils';
|
||||
import { ImportFileMappingFormValues } from './_types';
|
||||
|
||||
export const getFieldKey = (key: string, group = '') => {
|
||||
return group ? `${group}.${key}` : key;
|
||||
};
|
||||
|
||||
type ImportFileMappingRes = { from: string; to: string; group: string }[];
|
||||
|
||||
/**
|
||||
* Transformes the mapping form values to request.
|
||||
* @param {ImportFileMappingFormValues} value
|
||||
* @returns {ImportFileMappingRes[]}
|
||||
*/
|
||||
export const transformValueToReq = (
|
||||
value: ImportFileMappingFormValues,
|
||||
): { mapping: ImportFileMappingRes[] } => {
|
||||
const mapping = chain(value)
|
||||
.thru(deepdash.index)
|
||||
.pickBy((_value, key) => !isEmpty(_.get(value, key)))
|
||||
.map((from, key) => ({
|
||||
from,
|
||||
to: key.includes('.') ? last(key.split('.')) : key,
|
||||
group: key.includes('.') ? head(key.split('.')) : '',
|
||||
}))
|
||||
.value();
|
||||
|
||||
return { mapping };
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
export const transformResToFormValues = (
|
||||
value: { from: string; to: string }[],
|
||||
) => {
|
||||
return value?.reduce((acc, map) => {
|
||||
acc[map.to] = map.from;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the initial values of mapping form.
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export const useImportFileMappingInitialValues = () => {
|
||||
const { importFile } = useImportFileMapBootContext();
|
||||
const { entityColumns, sheetColumns } = useImportFileContext();
|
||||
|
||||
const initialResValues = useMemo(
|
||||
() => transformResToFormValues(importFile?.map || []),
|
||||
[importFile?.map],
|
||||
);
|
||||
|
||||
const initialValues = useMemo(
|
||||
() =>
|
||||
entityColumns.reduce((acc, { fields, groupKey }) => {
|
||||
fields.forEach(({ key, name }) => {
|
||||
const _name = lowerCase(name);
|
||||
const _matched = sheetColumns.find(
|
||||
(column) => lowerCase(column) === _name,
|
||||
);
|
||||
const _key = groupKey ? `${groupKey}.${key}` : key;
|
||||
const _value = _matched ? _matched : '';
|
||||
|
||||
set(acc, _key, _value);
|
||||
});
|
||||
return acc;
|
||||
}, {}),
|
||||
[entityColumns, sheetColumns],
|
||||
);
|
||||
|
||||
return useMemo<Record<string, any>>(
|
||||
() => ({
|
||||
...transformToForm(initialResValues, initialValues),
|
||||
...initialValues,
|
||||
}),
|
||||
[initialValues, initialResValues],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// @ts-nocheck
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { DashboardInsider } from '@/components';
|
||||
import { ImportView } from '@/containers/Import';
|
||||
|
||||
export default function BillsImport() {
|
||||
const history = useHistory();
|
||||
|
||||
const handleCancelBtnClick = () => {
|
||||
history.push('/bills');
|
||||
};
|
||||
const handleImportSuccess = () => {
|
||||
history.push('/bills');
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardInsider name={'import-bills'}>
|
||||
<ImportView
|
||||
resource={'bills'}
|
||||
onCancelClick={handleCancelBtnClick}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
/>
|
||||
</DashboardInsider>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,11 @@ function BillActionsBar({
|
||||
addSetting('bills', 'tableSize', size);
|
||||
};
|
||||
|
||||
// Handle the import button click.
|
||||
const handleImportBtnClick = () => {
|
||||
history.push('/bills/import');
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardActionsBar>
|
||||
<NavbarGroup>
|
||||
@@ -130,6 +135,7 @@ function BillActionsBar({
|
||||
className={Classes.MINIMAL}
|
||||
icon={<Icon icon={'file-import-16'} />}
|
||||
text={<T id={'import'} />}
|
||||
onClick={handleImportBtnClick}
|
||||
/>
|
||||
<Button
|
||||
className={Classes.MINIMAL}
|
||||
|
||||
@@ -51,6 +51,18 @@ export const getDashboardRoutes = () => [
|
||||
defaultSearchResource: RESOURCES_TYPES.MANUAL_JOURNAL,
|
||||
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
|
||||
},
|
||||
{
|
||||
path: `/manual-journals/import`,
|
||||
component: lazy(
|
||||
() => import('@/containers/Accounting/ManualJournalsImport'),
|
||||
),
|
||||
breadcrumb: intl.get('edit'),
|
||||
pageTitle: 'Manual Journals Import',
|
||||
sidebarExpand: false,
|
||||
backLink: true,
|
||||
defaultSearchResource: RESOURCES_TYPES.MANUAL_JOURNAL,
|
||||
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
|
||||
},
|
||||
{
|
||||
path: `/manual-journals`,
|
||||
component: lazy(
|
||||
@@ -893,6 +905,17 @@ export const getDashboardRoutes = () => [
|
||||
},
|
||||
|
||||
// Bills
|
||||
{
|
||||
path: `/bills/import`,
|
||||
component: lazy(() => import('@/containers/Purchases/Bills/BillImport')),
|
||||
name: 'bill-edit',
|
||||
// breadcrumb: intl.get('edit'),
|
||||
pageTitle: 'Bills Import',
|
||||
sidebarExpand: false,
|
||||
backLink: true,
|
||||
defaultSearchResource: RESOURCES_TYPES.BILL,
|
||||
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
|
||||
},
|
||||
{
|
||||
path: `/bills/:id/edit`,
|
||||
component: lazy(
|
||||
|
||||
Reference in New Issue
Block a user