fix: import resource imporements

This commit is contained in:
Ahmed Bouhuolia
2024-03-27 04:01:01 +02:00
parent 973d1832bd
commit ad4e51d81d
59 changed files with 1508 additions and 211 deletions

View File

@@ -27,7 +27,7 @@ export default class AccountsController extends BaseController {
/**
* Router constructor method.
*/
router() {
public router() {
const router = Router();
router.get(
@@ -98,7 +98,7 @@ export default class AccountsController extends BaseController {
/**
* Create account DTO Schema validation.
*/
get createAccountDTOSchema() {
private get createAccountDTOSchema() {
return [
check('name')
.exists()
@@ -131,7 +131,7 @@ export default class AccountsController extends BaseController {
/**
* Account DTO Schema validation.
*/
get editAccountDTOSchema() {
private get editAccountDTOSchema() {
return [
check('name')
.exists()
@@ -160,14 +160,14 @@ export default class AccountsController extends BaseController {
];
}
get accountParamSchema() {
private get accountParamSchema() {
return [param('id').exists().isNumeric().toInt()];
}
/**
* Accounts list validation schema.
*/
get accountsListSchema() {
private get accountsListSchema() {
return [
query('view_slug').optional({ nullable: true }).isString().trim(),
query('stringified_filter_roles').optional().isJSON(),

View File

@@ -160,10 +160,8 @@ export default class CustomersController extends ContactsController {
try {
const contact = await this.customersApplication.createCustomer(
tenantId,
contactDTO,
user
contactDTO
);
return res.status(200).send({
id: contact.id,
message: 'The customer has been created successfully.',
@@ -291,7 +289,7 @@ export default class CustomersController extends ContactsController {
const filter = {
inactiveMode: false,
sortOrder: 'desc',
columnSortBy: 'created_at',
columnSortBy: 'createdAt',
page: 1,
pageSize: 12,
...this.matchedQueryData(req),

View File

@@ -272,7 +272,7 @@ export default class VendorsController extends ContactsController {
const vendorsFilter: IVendorsFilter = {
inactiveMode: false,
sortOrder: 'desc',
columnSortBy: 'created_at',
columnSortBy: 'createdAt',
page: 1,
pageSize: 12,
...this.matchedQueryData(req),

View File

@@ -1,10 +1,12 @@
import { Inject, Service } from 'typedi';
import { Router, Request, Response, NextFunction } from 'express';
import { body, param } from 'express-validator';
import { body, param, query } from 'express-validator';
import { defaultTo } from 'lodash';
import BaseController from '@/api/controllers/BaseController';
import { ServiceError } from '@/exceptions';
import { ImportResourceApplication } from '@/services/Import/ImportResourceApplication';
import { uploadImportFile } from './_utils';
import { parseJsonSafe } from '@/utils/parse-json-safe';
@Service()
export class ImportController extends BaseController {
@@ -42,6 +44,17 @@ export class ImportController extends BaseController {
this.asyncMiddleware(this.mapping.bind(this)),
this.catchServiceErrors
);
router.get(
'/sample',
[query('resource').exists(), query('format').optional()],
this.downloadImportSample.bind(this),
this.catchServiceErrors
);
router.get(
'/:import_id',
this.asyncMiddleware(this.getImportFileMeta.bind(this)),
this.catchServiceErrors
);
router.get(
'/:import_id/preview',
this.asyncMiddleware(this.preview.bind(this)),
@@ -55,7 +68,7 @@ export class ImportController extends BaseController {
* @returns {ValidationSchema[]}
*/
private get importValidationSchema() {
return [body('resource').exists()];
return [body('resource').exists(), body('params').optional()];
}
/**
@@ -66,12 +79,15 @@ export class ImportController extends BaseController {
*/
private async fileUpload(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const body = this.matchedBodyData(req);
const params = defaultTo(parseJsonSafe(body.params), {});
try {
const data = await this.importResourceApp.import(
tenantId,
req.body.resource,
req.file.filename
body.resource,
req.file.filename,
params
);
return res.status(200).send(data);
} catch (error) {
@@ -140,6 +156,54 @@ export class ImportController extends BaseController {
}
}
/**
* Retrieves the csv/xlsx sample sheet of the given resource name.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private async downloadImportSample(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { format, resource } = this.matchedQueryData(req);
try {
const result = this.importResourceApp.sample(tenantId, resource, format);
return res.status(200).send(result);
} catch (error) {
next(error);
}
}
/**
* Retrieves the import file meta.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private async getImportFileMeta(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { import_id: importId } = req.params;
try {
const result = await this.importResourceApp.importMeta(
tenantId,
importId
);
return res.status(200).send(result);
} catch (error) {
next(error);
}
}
/**
* Transforms service errors to response.
* @param {Error}
@@ -174,7 +238,11 @@ export class ImportController extends BaseController {
errors: [{ type: 'IMPORTED_FILE_EXTENSION_INVALID' }],
});
}
return res.status(400).send({
errors: [{ type: error.errorType }],
});
}
next(error);
}
}

View File

@@ -6,6 +6,7 @@ exports.up = function (knex) {
table.string('resource');
table.json('columns');
table.json('mapping');
table.json('params');
table.timestamps();
});
};

View File

@@ -15,6 +15,7 @@ export default {
unique: true,
required: true,
importable: true,
exportable: true,
order: 1,
},
description: {
@@ -22,6 +23,7 @@ export default {
column: 'description',
fieldType: 'text',
importable: true,
exportable: true,
},
slug: {
name: 'account.field.slug',
@@ -35,6 +37,7 @@ export default {
name: 'account.field.code',
column: 'code',
fieldType: 'text',
exportable: true,
importable: true,
minLength: 3,
maxLength: 6,
@@ -75,6 +78,7 @@ export default {
})),
required: true,
importable: true,
exportable: true,
order: 2,
},
active: {
@@ -82,6 +86,7 @@ export default {
column: 'active',
fieldType: 'boolean',
filterable: false,
exportable: true,
importable: true,
},
balance: {
@@ -96,6 +101,7 @@ export default {
fieldType: 'text',
filterable: false,
importable: true,
exportable: true,
},
parentAccount: {
name: 'account.field.parent_account',
@@ -109,6 +115,7 @@ export default {
column: 'created_at',
fieldType: 'date',
importable: false,
exportable: true,
},
},
};

View File

@@ -1,70 +1,89 @@
export default {
importable: true,
defaultFilterField: 'displayName',
defaultSort: {
sortOrder: 'DESC',
sortField: 'createdAt',
},
fields: {
first_name: {
firstName: {
name: 'customer.field.first_name',
column: 'first_name',
fieldType: 'text',
importable: true,
},
last_name: {
lastName: {
name: 'customer.field.last_name',
column: 'last_name',
fieldType: 'text',
importable: true,
},
display_name: {
displayName: {
name: 'customer.field.display_name',
column: 'display_name',
fieldType: 'text',
required: true,
importable: true,
},
email: {
name: 'customer.field.email',
column: 'email',
fieldType: 'text',
importable: true,
},
work_phone: {
workPhone: {
name: 'customer.field.work_phone',
column: 'work_phone',
fieldType: 'text',
importable: true,
},
personal_phone: {
personalPhone: {
name: 'customer.field.personal_phone',
column: 'personal_phone',
fieldType: 'text',
importable: true,
},
company_name: {
companyName: {
name: 'customer.field.company_name',
column: 'company_name',
fieldType: 'text',
importable: true,
},
website: {
name: 'customer.field.website',
column: 'website',
fieldType: 'text',
},
created_at: {
name: 'customer.field.created_at',
column: 'created_at',
fieldType: 'date',
importable: true,
},
balance: {
name: 'customer.field.balance',
column: 'balance',
fieldType: 'number',
},
opening_balance: {
openingBalance: {
name: 'customer.field.opening_balance',
column: 'opening_balance',
fieldType: 'number',
importable: true,
},
opening_balance_at: {
openingBalanceAt: {
name: 'customer.field.opening_balance_at',
column: 'opening_balance_at',
filterable: false,
fieldType: 'date',
importable: true,
},
currency_code: {
openingBalanceExchangeRate: {
name: 'Opening Balance Ex. Rate',
column: 'opening_balance_exchange_rate',
fieldType: 'number',
importable: true,
},
currencyCode: {
name: 'customer.field.currency',
column: 'currency_code',
fieldType: 'text',
importable: true,
},
status: {
name: 'customer.field.status',
@@ -76,6 +95,99 @@ export default {
{ key: 'unpaid', label: 'customer.field.status.unpaid' },
],
filterCustomQuery: statusFieldFilterQuery,
importable: true,
},
// Billing Address
billingAddress1: {
name: 'Billing Address 1',
column: 'billing_address1',
fieldType: 'text',
importable: true,
},
billingAddress2: {
name: 'Billing Address 2',
column: 'billing_address2',
fieldType: 'text',
importable: true,
},
billingAddressCity: {
name: 'Billing Address City',
column: 'billing_address_city',
fieldType: 'text',
importable: true,
},
billingAddressCountry: {
name: 'Billing Address Country',
column: 'billing_address_country',
fieldType: 'text',
importable: true,
},
billingAddressPostcode: {
name: 'Billing Address Postcode',
column: 'billing_address_postcode',
fieldType: 'text',
importable: true,
},
billingAddressState: {
name: 'Billing Address State',
column: 'billing_address_state',
fieldType: 'text',
importable: true,
},
billingAddressPhone: {
name: 'Billing Address Phone',
column: 'billing_address_phone',
fieldType: 'text',
importable: true,
},
// Shipping Address
shippingAddress1: {
name: 'Shipping Address 1',
column: 'shipping_address1',
fieldType: 'text',
importable: true,
},
shippingAddress2: {
name: 'Shipping Address 2',
column: 'shipping_address2',
fieldType: 'text',
importable: true,
},
shippingAddressCity: {
name: 'Shipping Address City',
column: 'shipping_address_city',
fieldType: 'text',
importable: true,
},
shippingAddressCountry: {
name: 'Shipping Address Country',
column: 'shipping_address_country',
fieldType: 'text',
importable: true,
},
shippingAddressPostcode: {
name: 'Shipping Address Postcode',
column: 'shipping_address_postcode',
fieldType: 'text',
importable: true,
},
shippingAddressPhone: {
name: 'Shipping Address Phone',
column: 'shipping_address_phone',
fieldType: 'text',
importable: true,
},
shippingAddressState: {
name: 'Shipping Address State',
column: 'shipping_address_state',
fieldType: 'text',
importable: true,
},
//
createdAt: {
name: 'customer.field.created_at',
column: 'created_at',
fieldType: 'date',
},
},
};

View File

@@ -1,8 +1,10 @@
import TenantModel from 'models/TenantModel';
export default class Import extends TenantModel {
resource!: string;
mapping!: string;
columns!: string;
params!: Record<string, any>;
/**
* Table name.
@@ -49,6 +51,14 @@ export default class Import extends TenantModel {
}
public get paramsParsed() {
try {
return JSON.parse(this.params);
} catch {
return [];
}
}
public get mappingParsed() {
try {
return JSON.parse(this.mapping);

View File

@@ -0,0 +1,54 @@
export default {
defaultFilterField: 'createdAt',
defaultSort: {
sortOrder: 'DESC',
sortField: 'createdAt',
},
importable: true,
fields: {
date: {
name: 'Date',
column: 'date',
fieldType: 'date',
importable: true,
required: true,
},
payee: {
name: 'Payee',
column: 'payee',
fieldType: 'text',
importable: true,
},
description: {
name: 'Description',
column: 'description',
fieldType: 'text',
importable: true,
},
referenceNo: {
name: 'Reference No.',
column: 'reference_no',
fieldType: 'text',
importable: true,
},
amount: {
name: 'Amount',
column: 'Amount',
fieldType: 'numeric',
required: true,
importable: true,
},
account: {
name: 'Account',
column: 'account_id',
fieldType: 'relation',
to: { model: 'Account', to: 'id' },
},
createdAt: {
name: 'Created At',
column: 'createdAt',
fieldType: 'date',
importable: false,
},
},
};

View File

@@ -1,9 +1,15 @@
/* eslint-disable global-require */
import * as R from 'ramda';
import { Model, ModelOptions, QueryContext, mixin } from 'objection';
import TenantModel from 'models/TenantModel';
import { Model, ModelOptions, QueryContext } from 'objection';
import ModelSettings from './ModelSetting';
import Account from './Account';
import UncategorizedCashflowTransactionMeta from './UncategorizedCashflowTransaction.meta';
export default class UncategorizedCashflowTransaction extends TenantModel {
export default class UncategorizedCashflowTransaction extends mixin(
TenantModel,
[ModelSettings]
) {
id!: number;
amount!: number;
categorized!: boolean;
@@ -35,6 +41,10 @@ export default class UncategorizedCashflowTransaction extends TenantModel {
];
}
static get meta() {
return UncategorizedCashflowTransactionMeta;
}
/**
* Retrieves the withdrawal amount.
* @returns {number}

View File

@@ -1,74 +1,88 @@
export default {
defaultFilterField: 'display_name',
defaultFilterField: 'displayName',
defaultSort: {
sortOrder: 'DESC',
sortField: 'created_at',
sortField: 'createdAt',
},
importable: true,
fields: {
first_name: {
firstName: {
name: 'vendor.field.first_name',
column: 'first_name',
fieldType: 'text',
importable: true,
},
last_name: {
lastName: {
name: 'vendor.field.last_name',
column: 'last_name',
fieldType: 'text',
importable: true,
},
display_name: {
displayName: {
name: 'vendor.field.display_name',
column: 'display_name',
fieldType: 'text',
required: true,
importable: true,
},
email: {
name: 'vendor.field.email',
column: 'email',
fieldType: 'text',
importable: true,
},
work_phone: {
workPhone: {
name: 'vendor.field.work_phone',
column: 'work_phone',
fieldType: 'text',
importable: true,
},
personal_phone: {
personalPhone: {
name: 'vendor.field.personal_pone',
column: 'personal_phone',
fieldType: 'text',
importable: true,
},
company_name: {
companyName: {
name: 'vendor.field.company_name',
column: 'company_name',
fieldType: 'text',
importable: true,
},
website: {
name: 'vendor.field.website',
column: 'website',
fieldType: 'text',
},
created_at: {
name: 'vendor.field.created_at',
column: 'created_at',
fieldType: 'date',
importable: true,
},
balance: {
name: 'vendor.field.balance',
column: 'balance',
fieldType: 'number',
},
opening_balance: {
openingBalance: {
name: 'vendor.field.opening_balance',
column: 'opening_balance',
fieldType: 'number',
importable: true,
},
opening_balance_at: {
openingBalanceAt: {
name: 'vendor.field.opening_balance_at',
column: 'opening_balance_at',
fieldType: 'date',
importable: true,
},
currency_code: {
openingBalanceExchangeRate: {
name: 'Opening Balance Ex. Rate',
column: 'opening_balance_exchange_rate',
fieldType: 'number',
importable: true,
},
currencyCode: {
name: 'vendor.field.currency',
column: 'currency_code',
fieldType: 'text',
importable: true,
},
status: {
name: 'vendor.field.status',
@@ -87,6 +101,98 @@ export default {
break;
}
},
importable: true,
},
// Billing Address
billingAddress1: {
name: 'Billing Address 1',
column: 'billing_address1',
fieldType: 'text',
importable: true,
},
billingAddress2: {
name: 'Billing Address 2',
column: 'billing_address2',
fieldType: 'text',
importable: true,
},
billingAddressCity: {
name: 'Billing Address City',
column: 'billing_address_city',
fieldType: 'text',
importable: true,
},
billingAddressCountry: {
name: 'Billing Address Country',
column: 'billing_address_country',
fieldType: 'text',
importable: true,
},
billingAddressPostcode: {
name: 'Billing Address Postcode',
column: 'billing_address_postcode',
fieldType: 'text',
importable: true,
},
billingAddressState: {
name: 'Billing Address State',
column: 'billing_address_state',
fieldType: 'text',
importable: true,
},
billingAddressPhone: {
name: 'Billing Address Phone',
column: 'billing_address_phone',
fieldType: 'text',
importable: true,
},
// Shipping Address
shippingAddress1: {
name: 'Shipping Address 1',
column: 'shipping_address1',
fieldType: 'text',
importable: true,
},
shippingAddress2: {
name: 'Shipping Address 2',
column: 'shipping_address2',
fieldType: 'text',
importable: true,
},
shippingAddressCity: {
name: 'Shipping Address City',
column: 'shipping_address_city',
fieldType: 'text',
importable: true,
},
shippingAddressCountry: {
name: 'Shipping Address Country',
column: 'shipping_address_country',
fieldType: 'text',
importable: true,
},
shippingAddressPostcode: {
name: 'Shipping Address Postcode',
column: 'shipping_address_postcode',
fieldType: 'text',
importable: true,
},
shippingAddressState: {
name: 'Shipping Address State',
column: 'shipping_address_state',
fieldType: 'text',
importable: true,
},
shippingAddressPhone: {
name: 'Shipping Address Phone',
column: 'shipping_address_phone',
fieldType: 'text',
importable: true,
},
createdAt: {
name: 'vendor.field.created_at',
column: 'created_at',
fieldType: 'date',
},
},
};

View File

@@ -34,4 +34,12 @@ export class AccountsImportable extends Importable {
public get concurrency() {
return 1;
}
public public sampleData(): any[] {
return [
{
}
]
}
}

View File

@@ -1,7 +1,7 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import UnitOfWork, { IsolationLevel } from '../UnitOfWork';
import { Knex } from 'knex';
import UnitOfWork from '../UnitOfWork';
import { CreateUncategorizedTransactionDTO } from '@/interfaces';
@Service()
@@ -19,10 +19,10 @@ export class CreateUncategorizedTransaction {
*/
public create(
tenantId: number,
createDTO: CreateUncategorizedTransactionDTO
createDTO: CreateUncategorizedTransactionDTO,
trx?: Knex.Transaction
) {
const { UncategorizedCashflowTransaction, Account } =
this.tenancy.models(tenantId);
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
return this.uow.withTransaction(
tenantId,
@@ -32,9 +32,9 @@ export class CreateUncategorizedTransaction {
).insertAndFetch({
...createDTO,
});
return transaction;
},
trx
);
}
}

View File

@@ -0,0 +1,84 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import * as yup from 'yup';
import { Importable } from '../Import/Importable';
import { CreateUncategorizedTransaction } from './CreateUncategorizedTransaction';
import { CreateUncategorizedTransactionDTO } from '@/interfaces';
import { ImportableContext } from '../Import/interfaces';
import HasTenancyService from '../Tenancy/TenancyService';
import { ImportSampleData } from './constants';
@Service()
export class UncategorizedTransactionsImportable extends Importable {
@Inject()
private createUncategorizedTransaction: CreateUncategorizedTransaction;
@Inject()
private tenancy: HasTenancyService;
/**
* Passing the sheet DTO to create uncategorized transaction.
* @param {number} tenantId
* @param {number} tenantId
* @param {any} createDTO
* @param {Knex.Transaction} trx
*/
public async importable(
tenantId: number,
createDTO: CreateUncategorizedTransactionDTO,
trx?: Knex.Transaction
) {
return this.createUncategorizedTransaction.create(tenantId, createDTO, trx);
}
/**
* Transformes the DTO before validating and importing.
* @param {CreateUncategorizedTransactionDTO} createDTO
* @param {ImportableContext} context
* @returns {CreateUncategorizedTransactionDTO}
*/
public transform(
createDTO: CreateUncategorizedTransactionDTO,
context?: ImportableContext
): CreateUncategorizedTransactionDTO {
return {
...createDTO,
accountId: context.import.paramsParsed.accountId,
};
}
/**
* Sample data used to download sample sheet.
* @returns {Record<string, any>[]}
*/
public sampleData(): Record<string, any>[] {
return ImportSampleData;
}
/**
* Params validation schema.
* @returns {ValidationSchema[]}
*/
public paramsValidationSchema() {
return yup.object().shape({
accountId: yup.number().required(),
});
}
/**
* Validates the params existance asyncly.
* @param {number} tenantId -
* @param {Record<string, any>} params -
*/
public async validateParams(
tenantId: number,
params: Record<string, any>
): Promise<void> {
const { Account } = this.tenancy.models(tenantId);
if (params.accountId) {
await Account.query()
.findById(params.accountId)
.throwIfNotFound({});
}
}
}

View File

@@ -75,3 +75,25 @@ export interface ICashflowTransactionTypeMeta {
direction: CASHFLOW_DIRECTION;
creditType: string[];
}
export const ImportSampleData = [
{
Amount: 5000,
Date: '2024-01-01',
Payee: 'John Roberts',
Description: 'Cheque deposit',
},
{
Amount: 5000,
Date: '2024-01-01',
Payee: 'John Roberts',
Description: 'Cheque deposit',
},
{
Amount: 5000,
Date: '2024-01-01',
Payee: 'John Roberts',
Description: 'Cheque deposit',
},
]

View File

@@ -36,7 +36,7 @@ export class CreateCustomer {
public async createCustomer(
tenantId: number,
customerDTO: ICustomerNewDTO,
authorizedUser: ISystemUser
trx?: Knex.Transaction
): Promise<ICustomer> {
const { Contact } = this.tenancy.models(tenantId);
@@ -46,28 +46,31 @@ export class CreateCustomer {
customerDTO
);
// Creates a new customer under unit-of-work envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onCustomerCreating` event.
await this.eventPublisher.emitAsync(events.customers.onCreating, {
tenantId,
customerDTO,
trx,
} as ICustomerEventCreatingPayload);
return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
// Triggers `onCustomerCreating` event.
await this.eventPublisher.emitAsync(events.customers.onCreating, {
tenantId,
customerDTO,
trx,
} as ICustomerEventCreatingPayload);
// Creates a new contact as customer.
const customer = await Contact.query(trx).insertAndFetch({
...customerObj,
});
// Triggers `onCustomerCreated` event.
await this.eventPublisher.emitAsync(events.customers.onCreated, {
customer,
tenantId,
customerId: customer.id,
authorizedUser,
trx,
} as ICustomerEventCreatedPayload);
// Creates a new contact as customer.
const customer = await Contact.query(trx).insertAndFetch({
...customerObj,
});
// Triggers `onCustomerCreated` event.
await this.eventPublisher.emitAsync(events.customers.onCreated, {
customer,
tenantId,
customerId: customer.id,
trx,
} as ICustomerEventCreatedPayload);
return customer;
});
return customer;
},
trx
);
}
}

View File

@@ -1,6 +1,6 @@
import moment from 'moment';
import { defaultTo, omit, isEmpty } from 'lodash';
import { Service, Inject } from 'typedi';
import { Service } from 'typedi';
import {
ContactService,
ICustomer,
@@ -51,6 +51,10 @@ export class CreateEditCustomerDTO {
).toMySqlDateTime(),
}
: {}),
openingBalanceExchangeRate: defaultTo(
customerDTO.openingBalanceExchangeRate,
1
),
};
};

View File

@@ -4,7 +4,6 @@ import {
ICustomerEditDTO,
ICustomerEventEditedPayload,
ICustomerEventEditingPayload,
ISystemUser,
} from '@/interfaces';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import UnitOfWork from '@/services/UnitOfWork';

View File

@@ -53,13 +53,8 @@ export class CustomersApplication {
public createCustomer = (
tenantId: number,
customerDTO: ICustomerNewDTO,
authorizedUser: ISystemUser
) => {
return this.createCustomerService.createCustomer(
tenantId,
customerDTO,
authorizedUser
);
return this.createCustomerService.createCustomer(tenantId, customerDTO);
};
/**

View File

@@ -0,0 +1,26 @@
import { Inject, Service } from 'typedi';
import { Importable } from '@/services/Import/Importable';
import { CreateCustomer } from './CRUD/CreateCustomer';
import { Knex } from 'knex';
import { ICustomer, ICustomerNewDTO } from '@/interfaces';
@Service()
export class CustomersImportable extends Importable {
@Inject()
private createCustomerService: CreateCustomer;
/**
* Mapps the imported data to create a new customer service.
* @param {number} tenantId
* @param {ICustomerNewDTO} createDTO
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public async importable(
tenantId: number,
createDTO: ICustomerNewDTO,
trx?: Knex.Transaction<any, any[]>
): Promise<void> {
await this.createCustomerService.createCustomer(tenantId, createDTO, trx);
}
}

View File

@@ -49,6 +49,10 @@ export class CreateEditVendorDTO {
).toMySqlDateTime(),
}
: {}),
openingBalanceExchangeRate: defaultTo(
vendorDTO.openingBalanceExchangeRate,
1
),
};
};

View File

@@ -35,7 +35,7 @@ export class CreateVendor {
public async createVendor(
tenantId: number,
vendorDTO: IVendorNewDTO,
authorizedUser: ISystemUser
trx?: Knex.Transaction
) {
const { Contact } = this.tenancy.models(tenantId);
@@ -45,28 +45,31 @@ export class CreateVendor {
vendorDTO
);
// Creates vendor contact under unit-of-work evnirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onVendorCreating` event.
await this.eventPublisher.emitAsync(events.vendors.onCreating, {
tenantId,
vendorDTO,
trx,
} as IVendorEventCreatingPayload);
return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
// Triggers `onVendorCreating` event.
await this.eventPublisher.emitAsync(events.vendors.onCreating, {
tenantId,
vendorDTO,
trx,
} as IVendorEventCreatingPayload);
// Creates a new contact as vendor.
const vendor = await Contact.query(trx).insertAndFetch({
...vendorObject,
});
// Triggers `onVendorCreated` event.
await this.eventPublisher.emitAsync(events.vendors.onCreated, {
tenantId,
vendorId: vendor.id,
vendor,
authorizedUser,
trx,
} as IVendorEventCreatedPayload);
// Creates a new contact as vendor.
const vendor = await Contact.query(trx).insertAndFetch({
...vendorObject,
});
// Triggers `onVendorCreated` event.
await this.eventPublisher.emitAsync(events.vendors.onCreated, {
tenantId,
vendorId: vendor.id,
vendor,
trx,
} as IVendorEventCreatedPayload);
return vendor;
});
return vendor;
},
trx
);
}
}

View File

@@ -0,0 +1,24 @@
import { Importable } from '@/services/Import/Importable';
import { CreateVendor } from './CRUD/CreateVendor';
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
@Service()
export class VendorsImportable extends Importable {
@Inject()
private createVendorService: CreateVendor;
/**
* Maps the imported data to create a new vendor service.
* @param {number} tenantId
* @param {} createDTO
* @param {Knex.Transaction} trx
*/
public async importable(
tenantId: number,
createDTO: any,
trx?: Knex.Transaction<any, any[]>
): Promise<void> {
await this.createVendorService.createVendor(tenantId, createDTO, trx);
}
}

View File

@@ -7,16 +7,16 @@ import { first } from 'lodash';
import { ImportFileDataValidator } from './ImportFileDataValidator';
import { Knex } from 'knex';
import {
ImportInsertError,
ImportOperError,
ImportOperSuccess,
ImportableContext,
} from './interfaces';
import { AccountsImportable } from '../Accounts/AccountsImportable';
import { ServiceError } from '@/exceptions';
import { trimObject } from './_utils';
import { ImportableResources } from './ImportableResources';
import ResourceService from '../Resource/ResourceService';
import HasTenancyService from '../Tenancy/TenancyService';
import Import from '@/models/Import';
@Service()
export class ImportFileCommon {
@@ -39,12 +39,12 @@ export class ImportFileCommon {
* @returns {Record<string, any>[]} - The mapped data objects.
*/
public parseXlsxSheet(buffer: Buffer): Record<string, unknown>[] {
const workbook = XLSX.read(buffer, { type: 'buffer' });
const workbook = XLSX.read(buffer, { type: 'buffer', raw: true });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
return XLSX.utils.sheet_to_json(worksheet);
return XLSX.utils.sheet_to_json(worksheet, {});
}
/**
@@ -57,7 +57,7 @@ export class ImportFileCommon {
}
/**
* Imports the given parsed data to the resource storage through registered importable service.
* Imports the given parsed data to the resource storage through registered importable service.
* @param {number} tenantId -
* @param {string} resourceName - Resource name.
* @param {Record<string, any>} parsedData - Parsed data.
@@ -66,16 +66,16 @@ export class ImportFileCommon {
*/
public async import(
tenantId: number,
resourceName: string,
importFile: Import,
parsedData: Record<string, any>[],
trx?: Knex.Transaction
): Promise<[ImportOperSuccess[], ImportOperError[]]> {
const importableFields = this.resource.getResourceImportableFields(
tenantId,
resourceName
importFile.resource
);
const ImportableRegistry = this.importable.registry;
const importable = ImportableRegistry.getImportable(resourceName);
const importable = ImportableRegistry.getImportable(importFile.resource);
const concurrency = importable.concurrency || 10;
@@ -83,15 +83,25 @@ export class ImportFileCommon {
const failed: ImportOperError[] = [];
const importAsync = async (objectDTO, index: number): Promise<void> => {
const context: ImportableContext = {
rowIndex: index,
import: importFile,
};
const transformedDTO = importable.transform(objectDTO, context);
try {
// Validate the DTO object before passing it to the service layer.
await this.importFileValidator.validateData(
importableFields,
objectDTO
transformedDTO
);
try {
// Run the importable function and listen to the errors.
const data = await importable.importable(tenantId, objectDTO, trx);
const data = await importable.importable(
tenantId,
transformedDTO,
trx
);
success.push({ index, data });
} catch (err) {
if (err instanceof ServiceError) {
@@ -115,6 +125,60 @@ export class ImportFileCommon {
return [success, failed];
}
/**
*
* @param {string} resourceName
* @param {Record<string, any>} params
*/
public async validateParamsSchema(
resourceName: string,
params: Record<string, any>
) {
const ImportableRegistry = this.importable.registry;
const importable = ImportableRegistry.getImportable(resourceName);
const yupSchema = importable.paramsValidationSchema();
try {
await yupSchema.validate(params, { abortEarly: false });
} catch (validationError) {
const errors = validationError.inner.map((error) => ({
errorCode: 'ParamsValidationError',
errorMessage: error.errors,
}));
throw errors;
}
}
/**
*
* @param {string} resourceName
* @param {Record<string, any>} params
*/
public async validateParams(
tenantId: number,
resourceName: string,
params: Record<string, any>
) {
const ImportableRegistry = this.importable.registry;
const importable = ImportableRegistry.getImportable(resourceName);
await importable.validateParams(tenantId, params);
}
/**
*
* @param {string} resourceName
* @param {Record<string, any>} params
* @returns
*/
public transformParams(resourceName: string, params: Record<string, any>) {
const ImportableRegistry = this.importable.registry;
const importable = ImportableRegistry.getImportable(resourceName);
return importable.transformParams(params);
}
/**
* Retrieves the sheet columns from the given sheet data.
* @param {unknown[]} json

View File

@@ -1,7 +1,11 @@
import { fromPairs } from 'lodash';
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { ImportFileMapPOJO, ImportMappingAttr } from './interfaces';
import {
ImportDateFormats,
ImportFileMapPOJO,
ImportMappingAttr,
} from './interfaces';
import ResourceService from '../Resource/ResourceService';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './_utils';
@@ -37,12 +41,14 @@ export class ImportFileMapping {
// Validate the diplicated relations of map attrs.
this.validateDuplicatedMapAttrs(maps);
// Validate the date format mapping.
this.validateDateFormatMapping(tenantId, importFile.resource, maps);
const mappingStringified = JSON.stringify(maps);
await Import.query().findById(importFile.id).patch({
mapping: mappingStringified,
});
return {
import: {
importId: importFile.importId,
@@ -106,4 +112,34 @@ export class ImportFileMapping {
}
});
}
/**
* Validates the date format mapping.
* @param {number} tenantId
* @param {string} resource
* @param {ImportMappingAttr[]} maps
*/
private validateDateFormatMapping(
tenantId: number,
resource: string,
maps: ImportMappingAttr[]
) {
const fields = this.resource.getResourceImportableFields(
tenantId,
resource
);
maps.forEach((map) => {
if (
typeof fields[map.to] !== 'undefined' &&
fields[map.to].fieldType === 'date'
) {
if (
typeof map.dateFormat !== 'undefined' &&
ImportDateFormats.indexOf(map.dateFormat) === -1
) {
throw new ServiceError(ERRORS.INVALID_MAP_DATE_FORMAT);
}
}
});
}
}

View File

@@ -0,0 +1,32 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { ImportFileMetaTransformer } from './ImportFileMetaTransformer';
@Service()
export class ImportFileMeta {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
*
* @param {number} tenantId
* @param {number} importId
* @returns {}
*/
async getImportMeta(tenantId: number, importId: string) {
const { Import } = this.tenancy.models(tenantId);
const importFile = await Import.query().findOne('importId', importId);
// Retrieves the transformed accounts collection.
return this.transformer.transform(
tenantId,
importFile,
new ImportFileMetaTransformer()
);
}
}

View File

@@ -0,0 +1,19 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class ImportFileMetaTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['map'];
};
public excludeAttributes = (): string[] => {
return ['id', 'filename', 'columns', 'mappingParsed', 'mapping'];
}
map(importFile) {
return importFile.mappingParsed;
}
}

View File

@@ -67,12 +67,7 @@ export class ImportFileProcess {
const [successedImport, failedImport] = await this.uow.withTransaction(
tenantId,
(trx: Knex.Transaction) =>
this.importCommon.import(
tenantId,
importFile.resource,
parsedData,
trx
),
this.importCommon.import(tenantId, importFile, parsedData, trx),
trx
);
const mapping = importFile.mappingParsed;

View File

@@ -6,6 +6,7 @@ import { IModelMetaField } from '@/interfaces';
import { ImportFileCommon } from './ImportFileCommon';
import { ImportFileDataValidator } from './ImportFileDataValidator';
import { ImportFileUploadPOJO } from './interfaces';
import { ServiceError } from '@/exceptions';
@Service()
export class ImportFileUploadService {
@@ -32,13 +33,15 @@ export class ImportFileUploadService {
public async import(
tenantId: number,
resourceName: string,
filename: string
filename: string,
params: Record<string, number | string>
): Promise<ImportFileUploadPOJO> {
const { Import } = this.tenancy.models(tenantId);
const resource = sanitizeResourceName(resourceName);
const resourceMeta = this.resourceService.getResourceMeta(
tenantId,
resourceName
resource
);
// Throw service error if the resource does not support importing.
this.importValidator.validateResourceImportable(resourceMeta);
@@ -48,22 +51,32 @@ export class ImportFileUploadService {
// Parse the buffer file to array data.
const sheetData = this.importFileCommon.parseXlsxSheet(buffer);
const sheetColumns = this.importFileCommon.parseSheetColumns(sheetData);
const coumnsStringified = JSON.stringify(sheetColumns);
const _resourceName = sanitizeResourceName(resourceName);
try {
// Validates the params Yup schema.
await this.importFileCommon.validateParamsSchema(resource, params);
// Validates importable params asyncly.
await this.importFileCommon.validateParams(tenantId, resource, params);
} catch (error) {
throw error;
}
const _params = this.importFileCommon.transformParams(resource, params);
const paramsStringified = JSON.stringify(_params);
// Store the import model with related metadata.
const importFile = await Import.query().insert({
filename,
resource,
importId: filename,
resource: _resourceName,
columns: coumnsStringified,
params: paramsStringified,
});
const resourceColumnsMap = this.resourceService.getResourceImportableFields(
tenantId,
_resourceName
resource
);
const resourceColumns = this.getResourceColumns(resourceColumnsMap);

View File

@@ -4,6 +4,8 @@ import { ImportFileMapping } from './ImportFileMapping';
import { ImportMappingAttr } from './interfaces';
import { ImportFileProcess } from './ImportFileProcess';
import { ImportFilePreview } from './ImportFilePreview';
import { ImportSampleService } from './ImportSample';
import { ImportFileMeta } from './ImportFileMeta';
@Inject()
export class ImportResourceApplication {
@@ -19,25 +21,32 @@ export class ImportResourceApplication {
@Inject()
private ImportFilePreviewService: ImportFilePreview;
@Inject()
private importSampleService: ImportSampleService;
@Inject()
private importMetaService: ImportFileMeta;
/**
* Reads the imported file and stores the import file meta under unqiue id.
* @param {number} tenantId -
* @param {string} resource -
* @param {string} fileName -
* @param {string} resource - Resource name.
* @param {string} fileName - File name.
* @returns {Promise<ImportFileUploadPOJO>}
*/
public async import(
tenantId: number,
resource: string,
filename: string
filename: string,
params: Record<string, any>
) {
return this.importFileService.import(tenantId, resource, filename);
return this.importFileService.import(tenantId, resource, filename, params);
}
/**
* Mapping the excel sheet columns with resource columns.
* @param {number} tenantId
* @param {number} importId
* @param {number} importId - Import id.
* @param {ImportMappingAttr} maps
*/
public async mapping(
@@ -51,7 +60,7 @@ export class ImportResourceApplication {
/**
* Preview the mapped results before process importing.
* @param {number} tenantId
* @param {number} importId
* @param {number} importId - Import id.
* @returns {Promise<ImportFilePreviewPOJO>}
*/
public async preview(tenantId: number, importId: number) {
@@ -67,4 +76,27 @@ export class ImportResourceApplication {
public async process(tenantId: number, importId: number) {
return this.importProcessService.import(tenantId, importId);
}
/**
* Retrieves the import meta of the given import id.
* @param {number} tenantId -
* @param {string} importId - Import id.
* @returns {}
*/
public importMeta(tenantId: number, importId: string) {
return this.importMetaService.getImportMeta(tenantId, importId);
}
/**
* Retrieves the csv/xlsx sample sheet of the given
* @param {number} tenantId
* @param {number} resource - Resource name.
*/
public sample(
tenantId: number,
resource: string,
format: 'csv' | 'xlsx' = 'csv'
) {
return this.importSampleService.sample(tenantId, resource, format);
}
}

View File

@@ -0,0 +1,46 @@
import XLSX from 'xlsx';
import { Inject, Service } from 'typedi';
import { ImportableResources } from './ImportableResources';
import { sanitizeResourceName } from './_utils';
@Service()
export class ImportSampleService {
@Inject()
private importable: ImportableResources;
/**
* Retrieves the sample sheet of the given resource.
* @param {number} tenantId
* @param {string} resource
* @param {string} format
* @returns {Buffer | string}
*/
public sample(
tenantId: number,
resource: string,
format: 'csv' | 'xlsx'
): Buffer | string {
const _resource = sanitizeResourceName(resource);
const ImportableRegistry = this.importable.registry;
const importable = ImportableRegistry.getImportable(_resource);
const data = importable.sampleData();
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet(data);
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
// Determine the output format
if (format === 'csv') {
const csvOutput = XLSX.utils.sheet_to_csv(worksheet);
return csvOutput;
} else {
const xlsxOutput = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'buffer',
});
return xlsxOutput;
}
}
}

View File

@@ -1,4 +1,6 @@
import { Knex } from 'knex';
import * as Yup from 'yup';
import { ImportableContext } from './interfaces';
export abstract class Importable {
/**
@@ -13,6 +15,16 @@ export abstract class Importable {
);
}
/**
* Transformes the DTO before passing it to importable and validation.
* @param {Record<string, any>} createDTO
* @param {ImportableContext} context
* @returns {Record<string, any>}
*/
public transform(createDTO: Record<string, any>, context: ImportableContext) {
return createDTO;
}
/**
* Concurrency controlling of the importing process.
* @returns {number}
@@ -20,4 +32,41 @@ export abstract class Importable {
public get concurrency() {
return 10;
}
/**
* Retrieves the sample data of importable.
* @returns {Array<any>}
*/
public sampleData(): Array<any> {
return [];
}
// ------------------
// # Params
// ------------------
/**
* Params Yup validation schema.
* @returns {Yup.ObjectSchema<object, object>}
*/
public paramsValidationSchema(): Yup.ObjectSchema<object, object> {
return Yup.object().nullable();
}
/**
* Validates the params of the importable service.
* @param {Record<string, any>}
* @returns {Promise<boolean>} - True means passed and false failed.
*/
public async validateParams(
tenantId: number,
params: Record<string, any>
): Promise<void> {}
/**
* Transformes the import params before storing them.
* @param {Record<string, any>} parmas
*/
public transformParams(parmas: Record<string, any>) {
return parmas;
}
}

View File

@@ -1,6 +1,9 @@
import Container, { Service } from 'typedi';
import { AccountsImportable } from '../Accounts/AccountsImportable';
import { ImportableRegistry } from './ImportableRegistry';
import { UncategorizedTransactionsImportable } from '../Cashflow/UncategorizedTransactionsImportable';
import { CustomersImportable } from '../Contacts/Customers/CustomersImportable';
import { VendorsImportable } from '../Contacts/Vendors/VendorsImportable';
@Service()
export class ImportableResources {
@@ -15,6 +18,12 @@ export class ImportableResources {
*/
private importables = [
{ resource: 'Account', importable: AccountsImportable },
{
resource: 'UncategorizedCashflowTransaction',
importable: UncategorizedTransactionsImportable,
},
{ resource: 'Customer', importable: CustomersImportable },
{ resource: 'Vendor', importable: VendorsImportable },
];
public get registry() {

View File

@@ -3,6 +3,17 @@ import { upperFirst, camelCase, first } from 'lodash';
import pluralize from 'pluralize';
import { ResourceMetaFieldsMap } from './interfaces';
import { IModelMetaField } from '@/interfaces';
import moment from 'moment';
export const ERRORS = {
RESOURCE_NOT_IMPORTABLE: 'RESOURCE_NOT_IMPORTABLE',
INVALID_MAP_ATTRS: 'INVALID_MAP_ATTRS',
DUPLICATED_FROM_MAP_ATTR: 'DUPLICATED_FROM_MAP_ATTR',
DUPLICATED_TO_MAP_ATTR: 'DUPLICATED_TO_MAP_ATTR',
IMPORT_FILE_NOT_MAPPED: 'IMPORT_FILE_NOT_MAPPED',
INVALID_MAP_DATE_FORMAT: 'INVALID_MAP_DATE_FORMAT',
MAP_DATE_FORMAT_NOT_DEFINED: 'MAP_DATE_FORMAT_NOT_DEFINED',
};
export function trimObject(obj) {
return Object.entries(obj).reduce((acc, [key, value]) => {
@@ -47,6 +58,18 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
return acc;
}, {});
fieldSchema = Yup.string().oneOf(Object.keys(options)).label(field.name);
// Validate date field type.
} else if (field.fieldType === 'date') {
fieldSchema = fieldSchema.test(
'date validation',
'Invalid date or format. The string should be a valid YYYY-MM-DD format.',
(val) => {
if (!val) {
return true;
}
return moment(val, 'YYYY-MM-DD', true).isValid();
}
);
}
if (field.required) {
fieldSchema = fieldSchema.required();
@@ -56,14 +79,6 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
return Yup.object().shape(yupSchema);
};
export const ERRORS = {
RESOURCE_NOT_IMPORTABLE: 'RESOURCE_NOT_IMPORTABLE',
INVALID_MAP_ATTRS: 'INVALID_MAP_ATTRS',
DUPLICATED_FROM_MAP_ATTR: 'DUPLICATED_FROM_MAP_ATTR',
DUPLICATED_TO_MAP_ATTR: 'DUPLICATED_TO_MAP_ATTR',
IMPORT_FILE_NOT_MAPPED: 'IMPORT_FILE_NOT_MAPPED',
};
export const getUnmappedSheetColumns = (columns, mapping) => {
return columns.filter(
(column) => !mapping.some((map) => map.from === column)

View File

@@ -1,8 +1,10 @@
import { IModelMetaField } from '@/interfaces';
import Import from '@/models/Import';
export interface ImportMappingAttr {
from: string;
to: string;
dateFormat?: string;
}
export interface ImportValidationError {
@@ -59,3 +61,16 @@ export interface ImportOperError {
error: ImportInsertError;
index: number;
}
export interface ImportableContext {
import: Import,
rowIndex: number;
}
export const ImportDateFormats = [
'yyyy-MM-dd',
'dd.MM.yy',
'MM/dd/yy',
'dd/MMM/yyyy'
]

View File

@@ -0,0 +1,7 @@
export const parseJsonSafe = (value: string) => {
try {
return JSON.parse(value);
} catch {
return null;
}
};

View File

@@ -0,0 +1,25 @@
// @ts-nocheck
import { DashboardInsider } from '@/components';
import { ImportView } from '../Import/ImportView';
import { useHistory } from 'react-router-dom';
export default function AccountsImport() {
const history = useHistory();
const handleCancelBtnClick = () => {
history.push('/accounts');
};
const handleImportSuccess = () => {
history.push('/accounts');
};
return (
<DashboardInsider name={'import-accounts'}>
<ImportView
resource={'accounts'}
onCancelClick={handleCancelBtnClick}
onImportSuccess={handleImportSuccess}
/>
</DashboardInsider>
);
}

View File

@@ -7,6 +7,7 @@ import {
NavbarDivider,
Alignment,
} from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import {
Icon,
DashboardActionsBar,
@@ -48,6 +49,8 @@ function AccountTransactionsActionsBar({
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
const history = useHistory();
// Handle money in form
const handleMoneyInFormTransaction = (account) => {
openDialog('money-in', {
@@ -64,6 +67,11 @@ function AccountTransactionsActionsBar({
account_name: account.name,
});
};
// Handle import button click.
const handleImportBtnClick = () => {
history.push(`/cashflow-accounts/${accountId}/import`);
};
// Refresh cashflow infinity transactions hook.
const { refresh } = useRefreshCashflowTransactionsInfinity();
@@ -106,6 +114,7 @@ function AccountTransactionsActionsBar({
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 @@
// @ts-nocheck
import { DashboardInsider } from '@/components';
import { ImportView } from '@/containers/Import/ImportView';
import { useHistory, useParams } from 'react-router-dom';
export default function ImportUncategorizedTransactions() {
const history = useHistory();
const params = useParams();
const handleImportSuccess = () => {
history.push(
`/cashflow-accounts/${params.id}/transactions?filter=uncategorized`,
);
};
const handleCnacelBtnClick = () => {
history.push(
`/cashflow-accounts/${params.id}/transactions?filter=uncategorized`,
);
};
return (
<DashboardInsider name={'import-uncategorized-bank-transactions'}>
<ImportView
resource={'uncategorized_cashflow_transaction'}
params={{ accountId: params.id }}
onImportSuccess={handleImportSuccess}
onCancelClick={handleCnacelBtnClick}
sampleFileName={'sample_bank_transactions'}
/>
</DashboardInsider>
);
}

View File

@@ -0,0 +1,25 @@
// @ts-nocheck
import { DashboardInsider } from '@/components';
import { ImportView } from '../Import/ImportView';
import { useHistory } from 'react-router-dom';
export default function CustomersImport() {
const history = useHistory();
const handleImportSuccess = () => {
history.push('/customers');
};
const handleCancelBtnClick = () => {
history.push('/customers');
};
return (
<DashboardInsider name={'import-customers'}>
<ImportView
resource={'customers'}
onImportSuccess={handleImportSuccess}
onCancelClick={handleCancelBtnClick}
exampleTitle="Customers Example"
/>
</DashboardInsider>
);
}

View File

@@ -95,6 +95,11 @@ function CustomerActionsBar({
addSetting('customers', 'tableSize', size);
};
// Handle import button click.
const handleImportBtnClick = () => {
history.push('/customers/import');
};
return (
<DashboardActionsBar>
<NavbarGroup>
@@ -142,6 +147,7 @@ function CustomerActionsBar({
<Button
className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />}
onClick={handleImportBtnClick}
text={<T id={'import'} />}
/>
<Button

View File

@@ -9,9 +9,11 @@ import { useImportFileContext } from './ImportFileProvider';
export function ImportFileUploadFooterActions() {
const { isSubmitting } = useFormikContext();
const { setStep } = useImportFileContext();
const { onCancelClick } = useImportFileContext();
const handleCancelBtnClick = () => {};
const handleCancelBtnClick = () => {
onCancelClick && onCancelClick();
};
return (
<div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS, styles.root)}>

View File

@@ -7,33 +7,38 @@ import { ImportFileMappingForm } from './ImportFileMappingForm';
import { EntityColumn, useImportFileContext } from './ImportFileProvider';
import { CLASSES } from '@/constants';
import { ImportFileContainer } from './ImportFileContainer';
import styles from './ImportFileMapping.module.scss';
import { ImportStepperStep } from './_types';
import { ImportFileMapBootProvider } from './ImportFileMappingBoot';
import styles from './ImportFileMapping.module.scss';
export function ImportFileMapping() {
const { importId } = useImportFileContext();
return (
<ImportFileMappingForm>
<ImportFileContainer>
<p>
Review and map the column headers in your csv/xlsx file with the
Bigcapital fields.
</p>
<ImportFileMapBootProvider importId={importId}>
<ImportFileMappingForm>
<ImportFileContainer>
<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={styles.label}>Bigcapital Fields</th>
<th className={styles.field}>Sheet Column Headers</th>
</tr>
</thead>
<tbody>
<ImportFileMappingFields />
</tbody>
</table>
</ImportFileContainer>
<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>
</ImportFileContainer>
<ImportFileMappingFloatingActions />
</ImportFileMappingForm>
<ImportFileMappingFloatingActions />
</ImportFileMappingForm>
</ImportFileMapBootProvider>
);
}
@@ -67,6 +72,7 @@ function ImportFileMappingFields() {
</tr>
);
const columns = entityColumns.map(columnMapper);
return <>{columns}</>;
}
@@ -81,7 +87,7 @@ function ImportFileMappingFloatingActions() {
return (
<div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
<Group spacing={10}>
<Button onClick={handleCancelBtnClick}>Cancel</Button>
<Button onClick={handleCancelBtnClick}>Back</Button>
<Button type="submit" intent={Intent.PRIMARY} loading={isSubmitting}>
Next
</Button>

View File

@@ -0,0 +1,58 @@
import { Spinner } from '@blueprintjs/core';
import React, { createContext, useContext } from 'react';
import { Box } from '@/components';
import { useImportFileMeta } from '@/hooks/query/import';
interface ImportFileMapBootContextValue {}
const ImportFileMapBootContext = createContext<ImportFileMapBootContextValue>(
{} as ImportFileMapBootContextValue,
);
export const useImportFileMapBootContext = () => {
const context = useContext<ImportFileMapBootContextValue>(
ImportFileMapBootContext,
);
if (!context) {
throw new Error(
'useImportFileMapBootContext must be used within an ImportFileMapBootProvider',
);
}
return context;
};
interface ImportFileMapBootProps {
importId: string;
children: React.ReactNode;
}
export const ImportFileMapBootProvider = ({
importId,
children,
}: ImportFileMapBootProps) => {
const {
data: importFile,
isLoading: isImportFileLoading,
isFetching: isImportFileFetching,
} = useImportFileMeta(importId, {
enabled: Boolean(importId),
});
const value = {
importFile,
isImportFileLoading,
isImportFileFetching,
};
return (
<ImportFileMapBootContext.Provider value={value}>
{isImportFileLoading ? (
<Box style={{ padding: '2rem', textAlign: 'center' }}>
<Spinner size={26} />
</Box>
) : (
<>{children}</>
)}
</ImportFileMapBootContext.Provider>
);
};

View File

@@ -6,6 +6,8 @@ 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;
@@ -58,10 +60,23 @@ const transformValueToReq = (value: ImportFileMappingFormValues) => {
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();
return useMemo(
const initialResValues = useMemo(
() => transformResToFormValues(importFile?.map || []),
[importFile?.map],
);
const initialValues = useMemo(
() =>
entityColumns.reduce((acc, { key, name }) => {
const _name = lowerCase(name);
@@ -75,4 +90,12 @@ const useImportFileMappingInitialValues = () => {
}, {}),
[entityColumns, sheetColumns],
);
return useMemo(
() => ({
...transformToForm(initialResValues, initialValues),
...initialValues,
}),
[initialValues, initialResValues],
);
};

View File

@@ -1,7 +1,6 @@
// @ts-nocheck
import { Button, Callout, Intent, Text } from '@blueprintjs/core';
import clsx from 'classnames';
import { useHistory } from 'react-router-dom';
import {
ImportFilePreviewBootProvider,
useImportFilePreviewBootContext,
@@ -144,12 +143,12 @@ function ImportFilePreviewUnmapped() {
}
function ImportFilePreviewFloatingActions() {
const { importId, setStep } = useImportFileContext();
const { importId, setStep, onImportSuccess, onImportFailed } =
useImportFileContext();
const { importPreview } = useImportFilePreviewBootContext();
const { mutateAsync: importFile, isLoading: isImportFileLoading } =
useImportFileProcess();
const history = useHistory();
const isValidToImport = importPreview?.createdCount > 0;
const handleSubmitBtn = () => {
@@ -161,9 +160,11 @@ function ImportFilePreviewFloatingActions() {
importPreview.createdCount
} of ${10} has imported successfully.`,
});
history.push('/accounts');
onImportSuccess && onImportSuccess();
})
.catch((error) => {});
.catch((error) => {
onImportFailed && onImportFailed();
});
};
const handleCancelBtnClick = () => {
setStep(ImportStepperStep.Mapping);

View File

@@ -33,12 +33,36 @@ interface ImportFileContextValue {
setImportId: Dispatch<SetStateAction<string>>;
resource: string;
description?: string;
params: Record<string, any>;
onImportSuccess?: () => void;
onImportFailed?: () => void;
onCancelClick?: () => void;
sampleFileName?: string;
exampleDownload?: boolean;
exampleTitle?: string;
exampleDescription?: string;
}
interface ImportFileProviderProps {
resource: string;
description?: string;
params: Record<string, any>;
onImportSuccess?: () => void;
onImportFailed?: () => void;
onCancelClick?: () => void;
children: React.ReactNode;
sampleFileName?: string;
exampleDownload?: boolean;
exampleTitle?: string;
exampleDescription?: string;
}
const ExampleDescription =
'You can download the sample file to obtain detailed information about the data fields used during the import.';
const ExampleTitle = 'Table Example';
const ImportFileContext = createContext<ImportFileContextValue>(
{} as ImportFileContextValue,
);
@@ -57,6 +81,16 @@ export const useImportFileContext = () => {
export const ImportFileProvider = ({
resource,
children,
description,
params,
onImportFailed,
onImportSuccess,
onCancelClick,
sampleFileName,
exampleDownload = true,
exampleTitle = ExampleTitle,
exampleDescription = ExampleDescription,
}: ImportFileProviderProps) => {
const [sheetColumns, setSheetColumns] = useState<SheetColumn[]>([]);
const [entityColumns, setEntityColumns] = useState<SheetColumn[]>([]);
@@ -82,6 +116,18 @@ export const ImportFileProvider = ({
setImportId,
resource,
description,
params,
onImportSuccess,
onImportFailed,
onCancelClick,
sampleFileName,
exampleDownload,
exampleTitle,
exampleDescription,
};
return (

View File

@@ -29,7 +29,7 @@ export function ImportFileUploadForm({
formProps,
}: ImportFileUploadFormProps) {
const { mutateAsync: uploadImportFile } = useImportFileUpload();
const { setStep, setSheetColumns, setEntityColumns, setImportId } =
const { resource, params, setStep, setSheetColumns, setEntityColumns, setImportId } =
useImportFileContext();
const handleSubmit = (
@@ -41,7 +41,8 @@ export function ImportFileUploadForm({
setSubmitting(true);
const formData = new FormData();
formData.append('file', values.file);
formData.append('resource', 'Account');
formData.append('resource', resource);
formData.append('params', JSON.stringify(params));
uploadImportFile(formData)
.then(({ data }) => {

View File

@@ -1,22 +1,31 @@
// @ts-nocheck
import { Classes } from '@blueprintjs/core';
import { Stack } from '@/components';
import { ImportDropzone } from './ImportDropzone';
import { ImportSampleDownload } from './ImportSampleDownload';
import { ImportFileUploadForm } from './ImportFileUploadForm';
import { ImportFileUploadFooterActions } from './ImportFileFooterActions';
import { ImportFileContainer } from './ImportFileContainer';
import { useImportFileContext } from './ImportFileProvider';
export function ImportFileUploadStep() {
const { exampleDownload } = useImportFileContext();
return (
<ImportFileUploadForm>
<ImportFileContainer>
<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
className={Classes.TEXT_MUTED}
style={{ marginBottom: 18, lineHeight: 1.6 }}
>
Download a sample file and compare it with your import file to ensure
it is properly formatted. It's not necessary for the columns to be in
the same order, you can map them later.
</p>
<Stack spacing={40}>
<ImportDropzone />
<ImportSampleDownload />
{exampleDownload && <ImportSampleDownload />}
</Stack>
</ImportFileContainer>

View File

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

View File

@@ -1,23 +1,64 @@
// @ts-nocheck
import { Box, Group } from '@/components';
import { Button } from '@blueprintjs/core';
import { AppToaster, Box, Group } from '@/components';
import {
Button,
Intent,
Menu,
MenuItem,
Popover,
PopoverInteractionKind,
} from '@blueprintjs/core';
import styles from './ImportSampleDownload.module.scss';
import { useSampleSheetImport } from '@/hooks/query/import';
import { useImportFileContext } from './ImportFileProvider';
export function ImportSampleDownload() {
const { resource, sampleFileName, exampleTitle, exampleDescription } =
useImportFileContext();
const { mutateAsync: downloadSample } = useSampleSheetImport();
// Handle download button click.
const handleDownloadBtnClick = (format) => () => {
downloadSample({
filename: sampleFileName || `sample-${resource}`,
resource,
format: format,
})
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The sample sheet has been downloaded successfully.',
});
})
.catch((error) => {});
};
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>
<h3 className={styles.title}>{exampleTitle}</h3>
<p className={styles.description}>{exampleDescription}</p>
</Box>
<Box className={styles.buttonWrap}>
<Button minimal outlined>
Download File
</Button>
<Popover
content={
<Menu>
<MenuItem onClick={handleDownloadBtnClick('csv')} text={'CSV'} />
<MenuItem
onClick={handleDownloadBtnClick('xlsx')}
text={'XLSX'}
/>
</Menu>
}
interactionKind={PopoverInteractionKind.CLICK}
placement="bottom-start"
minimal
>
<Button minimal outlined>
Download File
</Button>
</Popover>
</Box>
</Group>
);

View File

@@ -0,0 +1,28 @@
// @ts-nocheck
import { ImportStepper } from './ImportStepper';
import { Box } from '@/components';
import { ImportFileProvider } from './ImportFileProvider';
import styles from './ImportView.module.scss';
interface ImportViewProps {
resource: string;
description?: string;
params: Record<string, any>;
onImportSuccess?: () => void;
onImportFailed?: () => void;
onCancelClick?: () => void;
sampleFileName?: string;
exampleDownload?: boolean;
exampleTitle?: string;
exampleDescription?: string;
}
export function ImportView({ ...props }: ImportViewProps) {
return (
<Box className={styles.root}>
<ImportFileProvider {...props}>
<ImportStepper />
</ImportFileProvider>
</Box>
);
}

View File

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

View File

@@ -0,0 +1,11 @@
// @ts-nocheck
import { DashboardInsider } from '@/components';
import { ImportView } from '../Import/ImportView';
export default function ItemsImport() {
return (
<DashboardInsider name={'import-items'}>
<ImportView resource={'items'} />
</DashboardInsider>
);
}

View File

@@ -0,0 +1,26 @@
// @ts-nocheck
import { useHistory } from 'react-router-dom';
import { DashboardInsider } from '@/components';
import { ImportView } from '../Import/ImportView';
export default function VendorsImport() {
const history = useHistory();
const handleImportSuccess = () => {
history.push('/vendors');
};
const handleImportBtnClick = () => {
history.push('/vendors');
};
return (
<DashboardInsider name={'import-vendors'}>
<ImportView
resource={'vendors'}
onImportSuccess={handleImportSuccess}
onCancelClick={handleImportBtnClick}
exampleTitle='Vendors Example'
/>
</DashboardInsider>
);
}

View File

@@ -83,6 +83,10 @@ function VendorActionsBar({
const handleTableRowSizeChange = (size) => {
addSetting('vendors', 'tableSize', size);
};
// Handle import button success.
const handleImportBtnSuccess = () => {
history.push('/vendors/import');
};
return (
<DashboardActionsBar>
<NavbarGroup>
@@ -128,6 +132,7 @@ function VendorActionsBar({
className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />}
text={<T id={'import'} />}
onClick={handleImportBtnSuccess}
/>
<Button
className={Classes.MINIMAL}

View File

@@ -2,7 +2,12 @@
import { useMutation, useQuery, useQueryClient } from 'react-query';
import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
import { downloadFile, useDownloadFile } from '../useDownloadFile';
const QueryKeys = {
ImportPreview: 'ImportPreview',
ImportFileMeta: 'ImportFileMeta',
};
/**
*
*/
@@ -28,22 +33,36 @@ export function useImportFileMapping(props = {}) {
{
onSuccess: (res, id) => {
// Invalidate queries.
queryClient.invalidateQueries([QueryKeys.ImportPreview]);
queryClient.invalidateQueries([QueryKeys.ImportFileMeta]);
},
...props,
},
);
}
export function useImportFilePreview(importId: string, props = {}) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useQuery(['importPreview', importId], () =>
return useQuery([QueryKeys.ImportPreview, importId], () =>
apiRequest
.get(`import/${importId}/preview`)
.then((res) => transformToCamelCase(res.data)),
);
}
export function useImportFileMeta(importId: string, props = {}) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useQuery([QueryKeys.ImportFileMeta, importId], () =>
apiRequest
.get(`import/${importId}`)
.then((res) => transformToCamelCase(res.data)),
);
}
/**
*
*/
@@ -61,3 +80,40 @@ export function useImportFileProcess(props = {}) {
},
);
}
interface SampleSheetImportQuery {
resource: string;
filename: string;
format: 'xlsx' | 'csv';
}
/**
* Initiates a download of the balance sheet in XLSX format.
* @param {Object} query - The query parameters for the request.
* @param {Object} args - Additional configurations for the download.
* @returns {Function} A function to trigger the file download.
*/
export const useSampleSheetImport = () => {
const apiRequest = useApiRequest();
return useMutation<void, AxiosError, IArgs>(
(data: SampleSheetImportQuery) => {
return apiRequest
.get('/import/sample', {
responseType: 'blob',
headers: {
accept:
data.format === 'xlsx' ? 'application/xlsx' : 'application/csv',
},
params: {
resource: data.resource,
format: data.format,
},
})
.then((res) => {
downloadFile(res.data, `${data.filename}.${data.format}`);
return res;
});
},
);
};

View File

@@ -6,13 +6,11 @@ import { RESOURCES_TYPES } from '@/constants/resourcesTypes';
const SUBSCRIPTION_TYPE = {
MAIN: 'main',
};
// const BASE_URL = '/dashboard';
export const getDashboardRoutes = () => [
// Accounts.
{
path: '/accounts/import',
component: lazy(() => import('@/containers/Import/ImportPage')),
component: lazy(() => import('@/containers/Accounts/AccountsImport')),
breadcrumb: 'Accounts Import',
pageTitle: 'Accounts Import',
},
@@ -77,6 +75,7 @@ export const getDashboardRoutes = () => [
},
// Items.
{
path: `/items/:id/edit`,
component: lazy(() => import('@/containers/Items/ItemFormPage')),
@@ -512,8 +511,20 @@ export const getDashboardRoutes = () => [
hotkey: 'shift+x',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
// Customers
{
path: `/customers/import`,
component: lazy(
() =>
import(
'@/containers/Customers/CustomersImport'
),
),
backLink: true,
pageTitle: 'Customers Import',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
defaultSearchResource: RESOURCES_TYPES.CUSTOMER,
},
{
path: `/customers/:id/edit`,
component: lazy(
@@ -564,6 +575,16 @@ export const getDashboardRoutes = () => [
},
// Vendors
{
path: `/vendors/import`,
component: lazy(
() => import('@/containers/Vendors/VendorsImport'),
),
backLink: true,
pageTitle: 'Vendors Import',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
defaultSearchResource: RESOURCES_TYPES.VENDOR,
},
{
path: `/vendors/:id/edit`,
component: lazy(
@@ -1031,6 +1052,19 @@ export const getDashboardRoutes = () => [
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
defaultSearchResource: RESOURCES_TYPES.ACCOUNT,
},
{
path: `/cashflow-accounts/:id/import`,
component: lazy(
() =>
import(
'@/containers/CashFlow/ImportIUncategorizedTransactions/ImportUncategorizedTransactionsPage'
),
),
backLink: true,
pageTitle: 'Bank Transactions Import',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
defaultSearchResource: RESOURCES_TYPES.ACCOUNT,
},
{
path: `/cashflow-accounts`,
component: lazy(