feat(webapp): import resource UI

This commit is contained in:
Ahmed Bouhuolia
2024-03-19 03:57:57 +02:00
parent 1ba26a3b85
commit ff5730d8a7
37 changed files with 1470 additions and 12 deletions

View File

@@ -0,0 +1,12 @@
.root {
padding: 20px;
border: 2px dotted #c5cbd3;
border-radius: 6px;
min-height: 200px;
display: flex;
flex-direction: column;
background: #fff;
position: relative;
}

View File

@@ -0,0 +1,266 @@
// @ts-nocheck
import React from 'react';
import clsx from 'classnames';
import {
Accept,
DropEvent,
FileError,
FileRejection,
FileWithPath,
useDropzone,
} from 'react-dropzone-esm';
import { DropzoneProvider } from './DropzoneProvider';
import { DropzoneAccept, DropzoneIdle, DropzoneReject } from './DropzoneStatus';
import { Box } from '../Layout';
import styles from './Dropzone.module.css';
import { CloudLoadingIndicator } from '../Indicator';
export type DropzoneStylesNames = 'root' | 'inner';
export type DropzoneVariant = 'filled' | 'light';
export type DropzoneCssVariables = {
root:
| '--dropzone-radius'
| '--dropzone-accept-color'
| '--dropzone-accept-bg'
| '--dropzone-reject-color'
| '--dropzone-reject-bg';
};
export interface DropzoneProps {
/** Key of `theme.colors` or any valid CSS color to set colors of `Dropzone.Accept`, `theme.primaryColor` by default */
acceptColor?: MantineColor;
/** Key of `theme.colors` or any valid CSS color to set colors of `Dropzone.Reject`, `'red'` by default */
rejectColor?: MantineColor;
/** Key of `theme.radius` or any valid CSS value to set `border-radius`, numbers are converted to rem, `theme.defaultRadius` by default */
radius?: MantineRadius;
/** Determines whether files capturing should be disabled, `false` by default */
disabled?: boolean;
/** Called when any files are dropped to the dropzone */
onDropAny?: (files: FileWithPath[], fileRejections: FileRejection[]) => void;
/** Called when valid files are dropped to the dropzone */
onDrop: (files: FileWithPath[]) => void;
/** Called when dropped files do not meet file restrictions */
onReject?: (fileRejections: FileRejection[]) => void;
/** Determines whether a loading overlay should be displayed over the dropzone, `false` by default */
loading?: boolean;
/** Mime types of the files that dropzone can accepts. By default, dropzone accepts all file types. */
accept?: Accept | string[];
/** A ref function which when called opens the file system file picker */
openRef?: React.ForwardedRef<() => void | undefined>;
/** Determines whether multiple files can be dropped to the dropzone or selected from file system picker, `true` by default */
multiple?: boolean;
/** Maximum file size in bytes */
maxSize?: number;
/** Name of the form control. Submitted with the form as part of a name/value pair. */
name?: string;
/** Maximum number of files that can be picked at once */
maxFiles?: number;
/** Set to autofocus the root element */
autoFocus?: boolean;
/** If `false`, disables click to open the native file selection dialog */
activateOnClick?: boolean;
/** If `false`, disables drag 'n' drop */
activateOnDrag?: boolean;
/** If `false`, disables Space/Enter to open the native file selection dialog. Note that it also stops tracking the focus state. */
activateOnKeyboard?: boolean;
/** If `false`, stops drag event propagation to parents */
dragEventsBubbling?: boolean;
/** Called when the `dragenter` event occurs */
onDragEnter?: (event: React.DragEvent<HTMLElement>) => void;
/** Called when the `dragleave` event occurs */
onDragLeave?: (event: React.DragEvent<HTMLElement>) => void;
/** Called when the `dragover` event occurs */
onDragOver?: (event: React.DragEvent<HTMLElement>) => void;
/** Called when user closes the file selection dialog with no selection */
onFileDialogCancel?: () => void;
/** Called when user opens the file selection dialog */
onFileDialogOpen?: () => void;
/** If `false`, allow dropped items to take over the current browser window */
preventDropOnDocument?: boolean;
/** Set to true to use the File System Access API to open the file picker instead of using an <input type="file"> click event, defaults to true */
useFsAccessApi?: boolean;
/** Use this to provide a custom file aggregator */
getFilesFromEvent?: (
event: DropEvent,
) => Promise<Array<File | DataTransferItem>>;
/** Custom validation function. It must return null if there's no errors. */
validator?: <T extends File>(file: T) => FileError | FileError[] | null;
/** Determines whether pointer events should be enabled on the inner element, `false` by default */
enablePointerEvents?: boolean;
/** Props passed down to the Loader component */
loaderProps?: LoaderProps;
/** Props passed down to the internal Input component */
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}
export type DropzoneFactory = Factory<{
props: DropzoneProps;
ref: HTMLDivElement;
stylesNames: DropzoneStylesNames;
vars: DropzoneCssVariables;
staticComponents: {
Accept: typeof DropzoneAccept;
Idle: typeof DropzoneIdle;
Reject: typeof DropzoneReject;
};
}>;
const defaultProps: Partial<DropzoneProps> = {
loading: false,
multiple: true,
maxSize: Infinity,
autoFocus: false,
activateOnClick: true,
activateOnDrag: true,
dragEventsBubbling: true,
activateOnKeyboard: true,
useFsAccessApi: true,
variant: 'light',
rejectColor: 'red',
};
export const Dropzone = (_props: DropzoneProps) => {
const {
// classNames,
// className,
// style,
// styles,
// unstyled,
// vars,
radius,
disabled,
loading,
multiple,
maxSize,
accept,
children,
onDropAny,
onDrop,
onReject,
openRef,
name,
maxFiles,
autoFocus,
activateOnClick,
activateOnDrag,
dragEventsBubbling,
activateOnKeyboard,
onDragEnter,
onDragLeave,
onDragOver,
onFileDialogCancel,
onFileDialogOpen,
preventDropOnDocument,
useFsAccessApi,
getFilesFromEvent,
validator,
rejectColor,
acceptColor,
enablePointerEvents,
loaderProps,
inputProps,
// mod,
classNames,
...others
} = {
...defaultProps,
..._props,
};
const { getRootProps, getInputProps, isDragAccept, isDragReject, open } =
useDropzone({
onDrop: onDropAny,
onDropAccepted: onDrop,
onDropRejected: onReject,
disabled: disabled || loading,
accept: Array.isArray(accept)
? accept.reduce((r, key) => ({ ...r, [key]: [] }), {})
: accept,
multiple,
maxSize,
maxFiles,
autoFocus,
noClick: !activateOnClick,
noDrag: !activateOnDrag,
noDragEventsBubbling: !dragEventsBubbling,
noKeyboard: !activateOnKeyboard,
onDragEnter,
onDragLeave,
onDragOver,
onFileDialogCancel,
onFileDialogOpen,
preventDropOnDocument,
useFsAccessApi,
validator,
...(getFilesFromEvent ? { getFilesFromEvent } : null),
});
const isIdle = !isDragAccept && !isDragReject;
return (
<DropzoneProvider
value={{ accept: isDragAccept, reject: isDragReject, idle: isIdle }}
>
<Box
{...getRootProps({
className: clsx(styles.root, classNames?.root),
})}
// {...getStyles('root', { focusable: true })}
{...others}
mod={[
{
accept: isDragAccept,
reject: isDragReject,
idle: isIdle,
loading,
'activate-on-click': activateOnClick,
},
// mod,
]}
>
<input {...getInputProps(inputProps)} name={name} />
<div
data-enable-pointer-events={enablePointerEvents || undefined}
className={classNames?.content}
>
{children}
</div>
</Box>
</DropzoneProvider>
);
};
Dropzone.displayName = '@mantine/dropzone/Dropzone';
Dropzone.Accept = DropzoneAccept;
Dropzone.Idle = DropzoneIdle;
Dropzone.Reject = DropzoneReject;

View File

@@ -0,0 +1,12 @@
import { createSafeContext } from './create-safe-context';
export interface DropzoneContextValue {
idle: boolean;
accept: boolean;
reject: boolean;
}
export const [DropzoneProvider, useDropzoneContext] =
createSafeContext<DropzoneContextValue>(
'Dropzone component was not found in tree',
);

View File

@@ -0,0 +1,36 @@
import React, { cloneElement } from 'react';
import { upperFirst } from 'lodash';
import { DropzoneContextValue, useDropzoneContext } from './DropzoneProvider';
import { isElement } from '@/utils/is-element';
export interface DropzoneStatusProps {
children: React.ReactNode;
}
type DropzoneStatusComponent = React.FC<DropzoneStatusProps>;
function createDropzoneStatus(status: keyof DropzoneContextValue) {
const Component: DropzoneStatusComponent = (props) => {
const { children, ...others } = props;
const ctx = useDropzoneContext();
const _children = isElement(children) ? children : <span>{children}</span>;
if (ctx[status]) {
return cloneElement(_children as JSX.Element, others);
}
return null;
};
Component.displayName = `@bigcapital/core/dropzone/${upperFirst(status)}`;
return Component;
}
export const DropzoneAccept = createDropzoneStatus('accept');
export const DropzoneReject = createDropzoneStatus('reject');
export const DropzoneIdle = createDropzoneStatus('idle');
export type DropzoneAcceptProps = DropzoneStatusProps;
export type DropzoneRejectProps = DropzoneStatusProps;
export type DropzoneIdleProps = DropzoneStatusProps;

View File

@@ -0,0 +1,25 @@
import React, { createContext, useContext } from 'react';
export function createSafeContext<ContextValue>(errorMessage: string) {
const Context = createContext<ContextValue | null>(null);
const useSafeContext = () => {
const ctx = useContext(Context);
if (ctx === null) {
throw new Error(errorMessage);
}
return ctx;
};
const Provider = ({
children,
value,
}: {
value: ContextValue;
children: React.ReactNode;
}) => <Context.Provider value={value}>{children}</Context.Provider>;
return [Provider, useSafeContext] as const;
}

View File

@@ -0,0 +1 @@
export * from './Dropzone';

View File

@@ -0,0 +1,39 @@
export const MIME_TYPES = {
// Images
png: 'image/png',
gif: 'image/gif',
jpeg: 'image/jpeg',
svg: 'image/svg+xml',
webp: 'image/webp',
avif: 'image/avif',
heic: 'image/heic',
// Documents
mp4: 'video/mp4',
zip: 'application/zip',
csv: 'text/csv',
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
exe: 'application/vnd.microsoft.portable-executable',
} as const;
export const IMAGE_MIME_TYPE = [
MIME_TYPES.png,
MIME_TYPES.gif,
MIME_TYPES.jpeg,
MIME_TYPES.svg,
MIME_TYPES.webp,
MIME_TYPES.avif,
MIME_TYPES.heic,
];
export const PDF_MIME_TYPE = [MIME_TYPES.pdf];
export const MS_WORD_MIME_TYPE = [MIME_TYPES.doc, MIME_TYPES.docx];
export const MS_EXCEL_MIME_TYPE = [MIME_TYPES.xls, MIME_TYPES.xlsx];
export const MS_POWERPOINT_MIME_TYPE = [MIME_TYPES.ppt, MIME_TYPES.pptx];
export const EXE_MIME_TYPE = [MIME_TYPES.exe];

View File

@@ -0,0 +1,111 @@
// @ts-nocheck
import { cloneElement } from 'react';
import styled from 'styled-components';
import { toArray } from 'lodash';
import { Box } from '../Layout';
import { StepperCompleted } from './StepperCompleted';
import { StepperStep } from './StepperStep';
import { StepperStepState } from './types';
export interface StepperProps {
/** <Stepper.Step /> components */
children: React.ReactNode;
/** Index of the active step */
active: number;
/** Called when step is clicked */
onStepClick?: (stepIndex: number) => void;
/** Determines whether next steps can be selected, `true` by default **/
allowNextStepsSelect?: boolean;
classNames?: Record<string, string>;
}
export function Stepper({
active,
onStepClick,
children,
classNames,
}: StepperProps) {
const convertedChildren = toArray(children) as React.ReactElement[];
const _children = convertedChildren.filter(
(child) => child.type !== StepperCompleted,
);
const completedStep = convertedChildren.find(
(item) => item.type === StepperCompleted,
);
const items = _children.reduce<React.ReactElement[]>((acc, item, index) => {
const state =
active === index
? StepperStepState.Progress
: active > index
? StepperStepState.Completed
: StepperStepState.Inactive;
const shouldAllowSelect = () => {
if (typeof onStepClick !== 'function') {
return false;
}
if (typeof item.props.allowStepSelect === 'boolean') {
return item.props.allowStepSelect;
}
return state === 'stepCompleted' || allowNextStepsSelect;
};
const isStepSelectionEnabled = shouldAllowSelect();
acc.push(
cloneElement(item, {
key: index,
step: index + 1,
state,
onClick: () => isStepSelectionEnabled && onStepClick?.(index),
allowStepClick: isStepSelectionEnabled,
}),
);
if (index !== _children.length - 1) {
acc.push(
<StepSeparator
data-active={index < active || undefined}
key={`separator-${index}`}
/>,
);
}
return acc;
}, []);
const stepContent = _children[active]?.props?.children;
const completedContent = completedStep?.props?.children;
const content =
active > _children.length - 1 ? completedContent : stepContent;
return (
<Box>
<StepsItems>{items}</StepsItems>
<StepsContent className={classNames?.content}>{content} </StepsContent>
</Box>
);
}
Stepper.Step = StepperStep;
Stepper.Completed = StepperCompleted;
Stepper.displayName = '@bigcapital/core/stepper';
const StepsItems = styled(Box)`
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
`;
const StepsContent = styled(Box)`
margin-top: 16px;
margin-bottom: 8px;
`;
const StepSeparator = styled.div`
flex: 1;
display: block;
border-color: #c5cbd3;
border-top-style: solid;
border-top-width: 1px;
`;

View File

@@ -0,0 +1,9 @@
import React from 'react';
export interface StepperCompletedProps {
/** Label content */
children: React.ReactNode;
}
export const StepperCompleted: React.FC<StepperCompletedProps> = () => null;
StepperCompleted.displayName = '@bigcapital/core/StepperCompleted';

View File

@@ -0,0 +1,102 @@
// @ts-nocheck
import { StepperStepState } from './types';
import styled from 'styled-components';
import { Icon } from '../Icon';
interface StepperStepProps {
label: string;
description?: string;
children: React.ReactNode;
step?: number;
active?: boolean;
state?: StepperStepState;
allowStepClick?: boolean;
}
export function StepperStep({
label,
description,
step,
active,
state,
children,
}: StepperStepProps) {
return (
<StepButton>
<StepIconWrap>
<StepIcon
isCompleted={state === StepperStepState.Completed}
isActive={state === StepperStepState.Progress}
>
{state === StepperStepState.Completed && (
<Icon icon={'done'} iconSize={24} />
)}
<StepIconText>{step}</StepIconText>
</StepIcon>
</StepIconWrap>
<StepTextWrap>
<StepTitle
isCompleted={state === StepperStepState.Completed}
isActive={state === StepperStepState.Progress}
>
{label}
</StepTitle>
{description && (
<StepDescription
isCompleted={state === StepperStepState.Completed}
isActive={state === StepperStepState.Progress}
>
{description}
</StepDescription>
)}
</StepTextWrap>
</StepButton>
);
}
const StepButton = styled.button`
background: transparent;
color: inherit;
border: 0;
align-items: center;
display: flex;
gap: 10px;
text-align: left;
`;
const StepIcon = styled.span`
display: block;
height: 24px;
width: 24px;
display: block;
line-height: 24px;
border-radius: 24px;
text-align: center;
background-color: ${(props) =>
props.isCompleted || props.isActive ? 'rgb(0, 82, 204)' : '#9e9e9e'};
color: #fff;
margin: auto;
font-size: 12px;
`;
const StepTitle = styled.div`
color: ${(props) =>
props.isCompleted || props.isActive ? 'rgb(0, 82, 204)' : '#738091'};
`;
const StepDescription = styled.div`
font-size: 12px;
margin-top: 10px;
color: ${(props) =>
props.isCompleted || props.isActive ? 'rgb(0, 82, 204)' : '#738091'};
`;
const StepIconWrap = styled.div`
display: flex;
`;
const StepTextWrap = styled.div`
text-align: left;
`;
const StepIconText = styled.div``;

View File

@@ -0,0 +1 @@
export * from './Stepper';

View File

@@ -0,0 +1,7 @@
export enum StepperStepState {
Progress = 'stepProgress',
Completed = 'stepCompleted',
Inactive = 'stepInactive',
}

View File

@@ -20,7 +20,7 @@ import {
DashboardActionViewsList,
DashboardFilterButton,
DashboardRowsHeightButton,
DashboardActionsBar
DashboardActionsBar,
} from '@/components';
import { AccountAction, AbilitySubject } from '@/constants/abilityOption';
@@ -37,6 +37,7 @@ import withSettings from '@/containers/Settings/withSettings';
import withSettingsActions from '@/containers/Settings/withSettingsActions';
import { compose } from '@/utils';
import { useHistory } from 'react-router-dom';
/**
* Accounts actions bar.
@@ -67,6 +68,8 @@ function AccountsActionsBar({
}) {
const { resourceViews, fields } = useAccountsChartContext();
const history = useHistory();
const onClickNewAccount = () => {
openDialog(DialogsName.AccountForm, {});
};
@@ -111,6 +114,11 @@ function AccountsActionsBar({
const handleTableRowSizeChange = (size) => {
addSetting('accounts', 'tableSize', size);
};
// handle the import button click.
const handleImportBtnClick = () => {
history.push('/accounts/import');
};
return (
<DashboardActionsBar>
<NavbarGroup>
@@ -183,6 +191,7 @@ function AccountsActionsBar({
className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />}
text={<T id={'import'} />}
onClick={handleImportBtnClick}
/>
<NavbarDivider />
<DashboardRowsHeightButton

View File

@@ -0,0 +1,32 @@
.root {
}
.title{
color: #5F6B7C;
font-weight: 600;
}
.iconWrap{
color: #8F99A8;
}
.subtitle {
color: #8F99A8;
}
.dropzoneContent{
text-align: center;
margin: auto;
}
.buttons-wrap {
margin-top: 12px;
}
.dropzoneHint{
display: flex;
font-size: 12px;
margin-top: 6px;
color: #8F99A8;
justify-content: space-between;
}

View File

@@ -0,0 +1,49 @@
// @ts-nocheck
import { Button } from '@blueprintjs/core';
import { Field } from 'formik';
import { Box, Group, Icon, Stack } from '@/components';
import { Dropzone } from '@/components/Dropzone';
import { MIME_TYPES } from '@/components/Dropzone/mine-types';
import styles from './ImportDropzone.module.css';
export function ImportDropzone() {
return (
<Stack spacing={0}>
<Field id={'file'} name={'file'} type="file">
{({ form: { setFieldValue } }) => (
<Dropzone
onDrop={(files) => setFieldValue('file', files[0])}
onReject={(files) => console.log('rejected files', files)}
maxSize={5 * 1024 ** 2}
accept={[MIME_TYPES.csv, MIME_TYPES.xls, MIME_TYPES.xlsx]}
classNames={{ content: styles.dropzoneContent }}
>
<Stack spacing={10} className={styles.content}>
<Box className={styles.iconWrap}>
<Icon icon="download" iconSize={26} />
</Box>
<Stack spacing={4}>
<h4 className={styles.title}>
Drag images here or click to select files
</h4>
<span className={styles.subtitle}>
Drag and Drop file here or Choose file
</span>
</Stack>
<Box>
<Button intent="none" minimal outlined>
Upload File
</Button>
</Box>
</Stack>
</Dropzone>
)}
</Field>
<Group className={styles.dropzoneHint}>
<Box>Supperted Formats: CSV, XLSX</Box>
<Box>Maximum size: 25MB</Box>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,21 @@
.table {
width: 100%;
margin-top: 1.8rem;
thead{
th{
border-top: 1px solid #d9d9da;
padding-top: 8px;
padding-bottom: 8px;
color: #738091;
}
th.label{
width: 32%;
}
}
tbody{
tr td {
vertical-align: middle;
}
}
}

View File

@@ -0,0 +1,74 @@
import { useMemo } from 'react';
import clsx from 'classnames';
import { FSelect, Group } from '@/components';
import { ImportFileMappingForm } from './ImportFileMappingForm';
import { useImportFileContext } from './ImportFileProvider';
import styles from './ImportFileMapping.module.scss';
import { CLASSES } from '@/constants';
import { Button, Intent } from '@blueprintjs/core';
import { useFormikContext } from 'formik';
export function ImportFileMapping() {
return (
<ImportFileMappingForm>
<p>
Review and map the column headers in your csv/xlsx file with the
Bigcapital fields.
</p>
<table className={clsx('bp4-html-table', styles.table)}>
<thead>
<tr>
<th className={'label'}>Bigcapital Fields</th>
<th className={'field'}>Sheet Column Headers</th>
</tr>
</thead>
<tbody>
<ImportFileMappingFields />
</tbody>
</table>
<ImportFileMappingFloatingActions />
</ImportFileMappingForm>
);
}
function ImportFileMappingFields() {
const { entityColumns, sheetColumns } = useImportFileContext();
const items = useMemo(
() => sheetColumns.map((column) => ({ value: column, text: column })),
[sheetColumns],
);
const columns = entityColumns.map((column, index) => (
<tr key={index}>
<td className={'label'}>{column.name}</td>
<td className={'field'}>
<FSelect
name={column.key}
items={items}
fill
popoverProps={{ minimal: false }}
minimal={false}
/>
</td>
</tr>
));
return <>{columns}</>;
}
function ImportFileMappingFloatingActions() {
const { isSubmitting } = useFormikContext();
return (
<div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
<Group>
<Button type="submit" intent={Intent.PRIMARY} loading={isSubmitting}>
Next
</Button>
<Button>Cancel</Button>
</Group>
</div>
);
}

View File

@@ -0,0 +1,70 @@
// @ts-nocheck
import { useImportFileMapping } from '@/hooks/query/import';
import { Form, Formik, FormikHelpers } from 'formik';
import { useImportFileContext } from './ImportFileProvider';
import { useMemo } from 'react';
import { isEmpty } from 'lodash';
const validationSchema = null;
interface ImportFileMappingFormProps {
children: React.ReactNode;
}
type ImportFileMappingFormValues = Record<string, string | null>;
export function ImportFileMappingForm({
children,
}: ImportFileMappingFormProps) {
const { mutateAsync: submitImportFileMapping } = useImportFileMapping();
const { importId, setStep } = useImportFileContext();
const initialValues = useImportFileMappingInitialValues();
const handleSubmit = (
values: ImportFileMappingFormValues,
{ setSubmitting }: FormikHelpers<ImportFileMappingFormValues>,
) => {
setSubmitting(true);
const _values = transformValueToReq(values);
submitImportFileMapping([importId, _values])
.then(() => {
setSubmitting(false);
setStep(2);
})
.catch((error) => {
setSubmitting(false);
});
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
// validationSchema={validationSchema}
>
<Form>{children}</Form>
</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 useImportFileMappingInitialValues = () => {
const { entityColumns } = useImportFileContext();
return useMemo(
() =>
entityColumns.reduce((acc, { key, name }) => {
acc[key] = '';
return acc;
}, {}),
[entityColumns],
);
};

View File

@@ -0,0 +1,121 @@
// @ts-nocheck
import { Button, Callout, Intent, Text } from '@blueprintjs/core';
import clsx from 'classnames';
import {
ImportFilePreviewBootProvider,
useImportFilePreviewBootContext,
} from './ImportFilePreviewBoot';
import { useImportFileContext } from './ImportFileProvider';
import { AppToaster, Card, Group } from '@/components';
import { useImportFileProcess } from '@/hooks/query/import';
import { CLASSES } from '@/constants';
import { useHistory } from 'react-router-dom';
export function ImportFilePreview() {
const { importId } = useImportFileContext();
return (
<ImportFilePreviewBootProvider importId={importId}>
<ImportFilePreviewContent />
</ImportFilePreviewBootProvider>
);
}
function ImportFilePreviewContent() {
const { importPreview } = useImportFilePreviewBootContext();
return (
<div>
<Callout>
{importPreview.createdCount} of {importPreview.totalCount} Items in your
file are ready to be imported.
</Callout>
<Card>
<Text>
Items that are ready to be imported - {importPreview.createdCount}
</Text>
<ul>
<li>Items to be created: ({importPreview.createdCount})</li>
<li>Items to be skipped: ({importPreview.skippedCount})</li>
<li>Items have errors: ({importPreview.errorsCount})</li>
</ul>
<table className={clsx('bp4-html-table')}>
<thead>
<tr>
<th className={'number'}>#</th>
<th className={'name'}>Name</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{importPreview?.errors.map((error, key) => (
<tr key={key}>
<td>{error.rowNumber}</td>
<td>{error.rowNumber}</td>
<td>
{error.errorMessage.map((message) => (
<div>{message}</div>
))}
</td>
</tr>
))}
</tbody>
</table>
<Text>
Unmapped Sheet Columns - ({importPreview?.unmappedColumnsCount})
</Text>
<ul>
{importPreview.unmappedColumns?.map((column, key) => (
<li key={key}>{column}</li>
))}
</ul>
</Card>
<ImportFilePreviewFloatingActions />
</div>
);
}
function ImportFilePreviewFloatingActions() {
const { importId } = useImportFileContext();
const { importPreview } = useImportFilePreviewBootContext();
const { mutateAsync: importFile, isLoading: isImportFileLoading } =
useImportFileProcess();
const history = useHistory();
const isValidToImport = importPreview?.createdCount > 0;
const handleSubmitBtn = () => {
importFile(importId)
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
message: `The ${
importPreview.createdCount
} of ${10} has imported successfully.`,
});
history.push('/accounts');
})
.catch((error) => {});
};
return (
<div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
<Group>
<Button
type="submit"
intent={Intent.PRIMARY}
loading={isImportFileLoading}
onClick={handleSubmitBtn}
disabled={!isValidToImport}
>
Import
</Button>
<Button>Cancel</Button>
</Group>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useImportFilePreview } from '@/hooks/query/import';
import { transformToCamelCase } from '@/utils';
import React, { createContext, useContext } from 'react';
interface ImportFilePreviewBootContextValue {}
const ImportFilePreviewBootContext =
createContext<ImportFilePreviewBootContextValue>(
{} as ImportFilePreviewBootContextValue,
);
export const useImportFilePreviewBootContext = () => {
const context = useContext<ImportFilePreviewBootContextValue>(
ImportFilePreviewBootContext,
);
if (!context) {
throw new Error(
'useImportFilePreviewBootContext must be used within an ImportFilePreviewBootProvider',
);
}
return context;
};
interface ImportFilePreviewBootProps {
importId: string;
children: React.ReactNode;
}
export const ImportFilePreviewBootProvider = ({
importId,
children,
}: ImportFilePreviewBootProps) => {
const {
data: importPreview,
isLoading: isImportPreviewLoading,
isFetching: isImportPreviewFetching,
} = useImportFilePreview(importId, {
enabled: Boolean(importId),
});
const value = {
importPreview,
isImportPreviewLoading,
isImportPreviewFetching,
};
return (
<ImportFilePreviewBootContext.Provider value={value}>
{isImportPreviewLoading ? 'loading' : <>{children}</>}
</ImportFilePreviewBootContext.Provider>
);
};

View File

@@ -0,0 +1,87 @@
// @ts-nocheck
import React, {
Dispatch,
SetStateAction,
createContext,
useContext,
useState,
} from 'react';
type EntityColumn = { key: string; name: string };
type SheetColumn = string;
type SheetMap = { from: string; to: string };
interface ImportFileContextValue {
sheetColumns: SheetColumn[];
setSheetColumns: Dispatch<SetStateAction<SheetColumn[]>>;
entityColumns: EntityColumn[];
setEntityColumns: Dispatch<SetStateAction<EntityColumn[]>>;
sheetMapping: SheetMap[];
setSheetMapping: Dispatch<SetStateAction<SheetMap[]>>;
step: number;
setStep: Dispatch<SetStateAction<number>>;
importId: string;
setImportId: Dispatch<SetStateAction<string>>;
resource: string;
}
interface ImportFileProviderProps {
resource: string;
children: React.ReactNode;
}
const ImportFileContext = createContext<ImportFileContextValue>(
{} as ImportFileContextValue,
);
export const useImportFileContext = () => {
const context = useContext<ImportFileContextValue>(ImportFileContext);
if (!context) {
throw new Error(
'useImportFileContext must be used within an ImportFileProvider',
);
}
return context;
};
export const ImportFileProvider = ({
resource,
children,
}: ImportFileProviderProps) => {
const [sheetColumns, setSheetColumns] = useState<SheetColumn[]>([]);
const [entityColumns, setEntityColumns] = useState<SheetColumn[]>([]);
const [sheetMapping, setSheetMapping] = useState<SheetMap[]>([]);
const [importId, setImportId] = useState<string>('');
const [step, setStep] = useState<number>(0);
const value = {
sheetColumns,
setSheetColumns,
entityColumns,
setEntityColumns,
sheetMapping,
setSheetMapping,
step,
setStep,
importId,
setImportId,
resource,
};
return (
<ImportFileContext.Provider value={value}>
{children}
</ImportFileContext.Provider>
);
};

View File

@@ -0,0 +1,62 @@
// @ts-nocheck
import { AppToaster } from '@/components';
import { useImportFileUpload } from '@/hooks/query/import';
import { Intent } from '@blueprintjs/core';
import { Formik, Form, FormikHelpers } from 'formik';
import * as Yup from 'yup';
import { useImportFileContext } from './ImportFileProvider';
const initialValues = {
file: null,
} as ImportFileUploadValues;
interface ImportFileUploadFormProps {
children: React.ReactNode;
}
const validationSchema = Yup.object().shape({
file: Yup.mixed().required('File is required'),
});
interface ImportFileUploadValues {
file: File | null;
}
export function ImportFileUploadForm({ children }: ImportFileUploadFormProps) {
const { mutateAsync: uploadImportFile } = useImportFileUpload();
const { setStep, setSheetColumns, setEntityColumns, setImportId } = useImportFileContext();
const handleSubmit = (
values: ImportFileUploadValues,
{ setSubmitting }: FormikHelpers<ImportFileUploadValues>,
) => {
if (!values.file) return;
setSubmitting(true);
const formData = new FormData();
formData.append('file', values.file);
formData.append('resource', 'Account');
uploadImportFile(formData)
.then(({ data }) => {
setImportId(data.import.import_id);
setSheetColumns(data.sheet_columns);
setEntityColumns(data.resource_columns);
setStep(1);
setSubmitting(false);
})
.catch((error) => {
setSubmitting(false);
});
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validationSchema}
>
<Form>{children}</Form>
</Formik>
);
}

View File

@@ -0,0 +1,3 @@
.root {
margin-top: 2.2rem
}

View File

@@ -0,0 +1,40 @@
// @ts-nocheck
import clsx from 'classnames';
import { Group, Stack } from '@/components';
import { ImportDropzone } from './ImportDropzone';
import { ImportSampleDownload } from './ImportSampleDownload';
import { CLASSES } from '@/constants';
import { Button, Intent } from '@blueprintjs/core';
import { ImportFileUploadForm } from './ImportFileUploadForm';
import { useFormikContext } from 'formik';
export function ImportFileUploadStep() {
return (
<ImportFileUploadForm>
<p style={{ marginBottom: 18 }}>
Download a sample file and compare it to your import file to ensure you
have the file perfect for the import.
</p>
<Stack spacing={40}>
<ImportDropzone />
<ImportSampleDownload />
</Stack>
<ImportFileUploadFooterActions />
</ImportFileUploadForm>
);
}
function ImportFileUploadFooterActions() {
const { isSubmitting } = useFormikContext();
return (
<div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
<Group spacing={10}>
<Button type="submit" intent={Intent.PRIMARY} loading={isSubmitting}>
Next
</Button>
<Button>Cancel</Button>
</Group>
</div>
);
}

View File

@@ -0,0 +1,11 @@
.root{
padding: 32px 40px;
min-width: 700px;
max-width: 800px;
width: 75%;
margin-left: auto;
margin-right: auto;
}
.rootWrap {
max-width: 1800px;
}

View File

@@ -0,0 +1,19 @@
// @ts-nocheck
import { ImportStepper } from './ImportStepper';
import { Box, DashboardInsider } from '@/components';
import styles from './ImportPage.module.scss';
import { ImportFileProvider } from './ImportFileProvider';
export default function ImportPage() {
return (
<DashboardInsider>
<Box className={styles.rootWrap}>
<Box className={styles.root}>
<ImportFileProvider resource="account">
<ImportStepper />
</ImportFileProvider>
</Box>
</Box>
</DashboardInsider>
);
}

View File

@@ -0,0 +1,23 @@
.root{
background: #fff;
border: 1px solid #D3D8DE;
border-radius: 5px;
padding: 16px;
}
.description{
margin: 0;
margin-top: 6px;
color: #8F99A8;
}
.title{
color: #5F6B7C;
font-weight: 600;
font-size: 14px;
}
.buttonWrap{
flex: 25% 0;
text-align: right;
}

View File

@@ -0,0 +1,24 @@
// @ts-nocheck
import { Box, Group } from '@/components';
import { Button } from '@blueprintjs/core';
import styles from './ImportSampleDownload.module.scss';
export function ImportSampleDownload() {
return (
<Group className={styles.root} noWrap>
<Box>
<h3 className={styles.title}>Table Example</h3>
<p className={styles.description}>
Download a sample file and compare it to your import file to ensure
you have the file perfect for the import.
</p>
</Box>
<Box className={styles.buttonWrap}>
<Button minimal outlined>
Download File
</Button>
</Box>
</Group>
);
}

View File

@@ -0,0 +1,3 @@
.content {
margin-top: 2.4rem;
}

View File

@@ -0,0 +1,28 @@
// @ts-nocheck
import { Stepper } from '@/components/Stepper';
import { ImportFileUploadStep } from './ImportFileUploadStep';
import styles from './ImportStepper.module.scss';
import { useImportFileContext } from './ImportFileProvider';
import { ImportFileMapping } from './ImportFileMapping';
import { ImportFilePreview } from './ImportFilePreview';
export function ImportStepper() {
const { step } = useImportFileContext();
return (
<Stepper active={step} classNames={{ content: styles.content }}>
<Stepper.Step label={'File Upload'}>
<ImportFileUploadStep />
</Stepper.Step>
<Stepper.Step label={'Mapping'}>
<ImportFileMapping />
</Stepper.Step>
<Stepper.Step label={'Results'}>
<ImportFilePreview />
</Stepper.Step>
</Stepper>
);
}

View File

@@ -0,0 +1,63 @@
// @ts-nocheck
import { useMutation, useQuery, useQueryClient } from 'react-query';
import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
/**
*
*/
export function useImportFileUpload(props = {}) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation((values) => apiRequest.post(`import/file`, values), {
onSuccess: (res, id) => {
// Invalidate queries.
},
...props,
});
}
export function useImportFileMapping(props = {}) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation(
([importId, values]) =>
apiRequest.post(`import/${importId}/mapping`, values),
{
onSuccess: (res, id) => {
// Invalidate queries.
},
...props,
},
);
}
export function useImportFilePreview(importId: string, props = {}) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useQuery(['importPreview', importId], () =>
apiRequest
.get(`import/${importId}/preview`)
.then((res) => transformToCamelCase(res.data)),
);
}
/**
*
*/
export function useImportFileProcess(props = {}) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation(
(importId) => apiRequest.post(`import/${importId}/import`),
{
onSuccess: (res, id) => {
// Invalidate queries.
},
...props,
},
);
}

View File

@@ -10,6 +10,12 @@ const SUBSCRIPTION_TYPE = {
export const getDashboardRoutes = () => [
// Accounts.
{
path: '/accounts/import',
component: lazy(() => import('@/containers/Import/ImportPage')),
breadcrumb: 'Import Accounts',
pageTitle: 'Import Accounts',
},
{
path: `/accounts`,
component: lazy(() => import('@/containers/Accounts/AccountsChart')),
@@ -19,7 +25,6 @@ export const getDashboardRoutes = () => [
defaultSearchResource: RESOURCES_TYPES.ACCOUNT,
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
// Accounting.
{
path: `/make-journal-entry`,
@@ -1062,8 +1067,7 @@ export const getDashboardRoutes = () => [
{
path: '/tax-rates',
component: lazy(
() =>
import('@/containers/TaxRates/pages/TaxRatesLanding'),
() => import('@/containers/TaxRates/pages/TaxRatesLanding'),
),
pageTitle: 'Tax Rates',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],

View File

@@ -577,4 +577,16 @@ export default {
],
viewBox: '0 0 20 20',
},
done: {
path: [
'M395-285 226-455l50-50 119 118 289-288 50 51-339 339Z',
],
viewBox: '0 -960 960 960'
},
download: {
path: [
'M480-336 288-528l51-51 105 105v-342h72v342l105-105 51 51-192 192ZM263.717-192Q234-192 213-213.15T192-264v-72h72v72h432v-72h72v72q0 29.7-21.162 50.85Q725.676-192 695.96-192H263.717Z'
],
viewBox: '0 -960 960 960'
}
};

View File

@@ -1,7 +1,6 @@
.cloud-spinner{
position: relative;
&.is-loading:before{
content: "";

View File

@@ -0,0 +1,17 @@
import React from 'react';
export function isElement(value: any): value is React.ReactElement {
if (Array.isArray(value) || value === null) {
return false;
}
if (typeof value === 'object') {
if (value.type === React.Fragment) {
return false;
}
return true;
}
return false;
}