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

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { Router, Request, Response, NextFunction } from 'express'; 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 BaseController from '@/api/controllers/BaseController';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { ImportResourceApplication } from '@/services/Import/ImportResourceApplication'; import { ImportResourceApplication } from '@/services/Import/ImportResourceApplication';
import { uploadImportFile } from './_utils'; import { uploadImportFile } from './_utils';
import { parseJsonSafe } from '@/utils/parse-json-safe';
@Service() @Service()
export class ImportController extends BaseController { export class ImportController extends BaseController {
@@ -42,6 +44,17 @@ export class ImportController extends BaseController {
this.asyncMiddleware(this.mapping.bind(this)), this.asyncMiddleware(this.mapping.bind(this)),
this.catchServiceErrors 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( router.get(
'/:import_id/preview', '/:import_id/preview',
this.asyncMiddleware(this.preview.bind(this)), this.asyncMiddleware(this.preview.bind(this)),
@@ -55,7 +68,7 @@ export class ImportController extends BaseController {
* @returns {ValidationSchema[]} * @returns {ValidationSchema[]}
*/ */
private get importValidationSchema() { 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) { private async fileUpload(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req; const { tenantId } = req;
const body = this.matchedBodyData(req);
const params = defaultTo(parseJsonSafe(body.params), {});
try { try {
const data = await this.importResourceApp.import( const data = await this.importResourceApp.import(
tenantId, tenantId,
req.body.resource, body.resource,
req.file.filename req.file.filename,
params
); );
return res.status(200).send(data); return res.status(200).send(data);
} catch (error) { } 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. * Transforms service errors to response.
* @param {Error} * @param {Error}
@@ -174,7 +238,11 @@ export class ImportController extends BaseController {
errors: [{ type: 'IMPORTED_FILE_EXTENSION_INVALID' }], errors: [{ type: 'IMPORTED_FILE_EXTENSION_INVALID' }],
}); });
} }
return res.status(400).send({
errors: [{ type: error.errorType }],
});
} }
next(error); next(error);
} }
} }

View File

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

View File

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

View File

@@ -1,70 +1,89 @@
export default { export default {
importable: true,
defaultFilterField: 'displayName',
defaultSort: {
sortOrder: 'DESC',
sortField: 'createdAt',
},
fields: { fields: {
first_name: { firstName: {
name: 'customer.field.first_name', name: 'customer.field.first_name',
column: 'first_name', column: 'first_name',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
last_name: { lastName: {
name: 'customer.field.last_name', name: 'customer.field.last_name',
column: 'last_name', column: 'last_name',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
display_name: { displayName: {
name: 'customer.field.display_name', name: 'customer.field.display_name',
column: 'display_name', column: 'display_name',
fieldType: 'text', fieldType: 'text',
required: true,
importable: true,
}, },
email: { email: {
name: 'customer.field.email', name: 'customer.field.email',
column: 'email', column: 'email',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
work_phone: { workPhone: {
name: 'customer.field.work_phone', name: 'customer.field.work_phone',
column: 'work_phone', column: 'work_phone',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
personal_phone: { personalPhone: {
name: 'customer.field.personal_phone', name: 'customer.field.personal_phone',
column: 'personal_phone', column: 'personal_phone',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
company_name: { companyName: {
name: 'customer.field.company_name', name: 'customer.field.company_name',
column: 'company_name', column: 'company_name',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
website: { website: {
name: 'customer.field.website', name: 'customer.field.website',
column: 'website', column: 'website',
fieldType: 'text', fieldType: 'text',
}, importable: true,
created_at: {
name: 'customer.field.created_at',
column: 'created_at',
fieldType: 'date',
}, },
balance: { balance: {
name: 'customer.field.balance', name: 'customer.field.balance',
column: 'balance', column: 'balance',
fieldType: 'number', fieldType: 'number',
}, },
opening_balance: { openingBalance: {
name: 'customer.field.opening_balance', name: 'customer.field.opening_balance',
column: 'opening_balance', column: 'opening_balance',
fieldType: 'number', fieldType: 'number',
importable: true,
}, },
opening_balance_at: { openingBalanceAt: {
name: 'customer.field.opening_balance_at', name: 'customer.field.opening_balance_at',
column: 'opening_balance_at', column: 'opening_balance_at',
filterable: false, filterable: false,
fieldType: 'date', 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', name: 'customer.field.currency',
column: 'currency_code', column: 'currency_code',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
status: { status: {
name: 'customer.field.status', name: 'customer.field.status',
@@ -76,6 +95,99 @@ export default {
{ key: 'unpaid', label: 'customer.field.status.unpaid' }, { key: 'unpaid', label: 'customer.field.status.unpaid' },
], ],
filterCustomQuery: statusFieldFilterQuery, 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'; import TenantModel from 'models/TenantModel';
export default class Import extends TenantModel { export default class Import extends TenantModel {
resource!: string;
mapping!: string; mapping!: string;
columns!: string; columns!: string;
params!: Record<string, any>;
/** /**
* Table name. * 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() { public get mappingParsed() {
try { try {
return JSON.parse(this.mapping); 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 */ /* eslint-disable global-require */
import * as R from 'ramda';
import { Model, ModelOptions, QueryContext, mixin } from 'objection';
import TenantModel from 'models/TenantModel'; import TenantModel from 'models/TenantModel';
import { Model, ModelOptions, QueryContext } from 'objection'; import ModelSettings from './ModelSetting';
import Account from './Account'; 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; id!: number;
amount!: number; amount!: number;
categorized!: boolean; categorized!: boolean;
@@ -35,6 +41,10 @@ export default class UncategorizedCashflowTransaction extends TenantModel {
]; ];
} }
static get meta() {
return UncategorizedCashflowTransactionMeta;
}
/** /**
* Retrieves the withdrawal amount. * Retrieves the withdrawal amount.
* @returns {number} * @returns {number}

View File

@@ -1,74 +1,88 @@
export default { export default {
defaultFilterField: 'display_name', defaultFilterField: 'displayName',
defaultSort: { defaultSort: {
sortOrder: 'DESC', sortOrder: 'DESC',
sortField: 'created_at', sortField: 'createdAt',
}, },
importable: true,
fields: { fields: {
first_name: { firstName: {
name: 'vendor.field.first_name', name: 'vendor.field.first_name',
column: 'first_name', column: 'first_name',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
last_name: { lastName: {
name: 'vendor.field.last_name', name: 'vendor.field.last_name',
column: 'last_name', column: 'last_name',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
display_name: { displayName: {
name: 'vendor.field.display_name', name: 'vendor.field.display_name',
column: 'display_name', column: 'display_name',
fieldType: 'text', fieldType: 'text',
required: true,
importable: true,
}, },
email: { email: {
name: 'vendor.field.email', name: 'vendor.field.email',
column: 'email', column: 'email',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
work_phone: { workPhone: {
name: 'vendor.field.work_phone', name: 'vendor.field.work_phone',
column: 'work_phone', column: 'work_phone',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
personal_phone: { personalPhone: {
name: 'vendor.field.personal_pone', name: 'vendor.field.personal_pone',
column: 'personal_phone', column: 'personal_phone',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
company_name: { companyName: {
name: 'vendor.field.company_name', name: 'vendor.field.company_name',
column: 'company_name', column: 'company_name',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
website: { website: {
name: 'vendor.field.website', name: 'vendor.field.website',
column: 'website', column: 'website',
fieldType: 'text', fieldType: 'text',
}, importable: true,
created_at: {
name: 'vendor.field.created_at',
column: 'created_at',
fieldType: 'date',
}, },
balance: { balance: {
name: 'vendor.field.balance', name: 'vendor.field.balance',
column: 'balance', column: 'balance',
fieldType: 'number', fieldType: 'number',
}, },
opening_balance: { openingBalance: {
name: 'vendor.field.opening_balance', name: 'vendor.field.opening_balance',
column: 'opening_balance', column: 'opening_balance',
fieldType: 'number', fieldType: 'number',
importable: true,
}, },
opening_balance_at: { openingBalanceAt: {
name: 'vendor.field.opening_balance_at', name: 'vendor.field.opening_balance_at',
column: 'opening_balance_at', column: 'opening_balance_at',
fieldType: 'date', 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', name: 'vendor.field.currency',
column: 'currency_code', column: 'currency_code',
fieldType: 'text', fieldType: 'text',
importable: true,
}, },
status: { status: {
name: 'vendor.field.status', name: 'vendor.field.status',
@@ -87,6 +101,98 @@ export default {
break; 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() { public get concurrency() {
return 1; return 1;
} }
public public sampleData(): any[] {
return [
{
}
]
}
} }

View File

@@ -1,7 +1,7 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import UnitOfWork, { IsolationLevel } from '../UnitOfWork'; import UnitOfWork from '../UnitOfWork';
import { Knex } from 'knex';
import { CreateUncategorizedTransactionDTO } from '@/interfaces'; import { CreateUncategorizedTransactionDTO } from '@/interfaces';
@Service() @Service()
@@ -19,10 +19,10 @@ export class CreateUncategorizedTransaction {
*/ */
public create( public create(
tenantId: number, tenantId: number,
createDTO: CreateUncategorizedTransactionDTO createDTO: CreateUncategorizedTransactionDTO,
trx?: Knex.Transaction
) { ) {
const { UncategorizedCashflowTransaction, Account } = const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
this.tenancy.models(tenantId);
return this.uow.withTransaction( return this.uow.withTransaction(
tenantId, tenantId,
@@ -32,9 +32,9 @@ export class CreateUncategorizedTransaction {
).insertAndFetch({ ).insertAndFetch({
...createDTO, ...createDTO,
}); });
return transaction; 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; direction: CASHFLOW_DIRECTION;
creditType: string[]; 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( public async createCustomer(
tenantId: number, tenantId: number,
customerDTO: ICustomerNewDTO, customerDTO: ICustomerNewDTO,
authorizedUser: ISystemUser trx?: Knex.Transaction
): Promise<ICustomer> { ): Promise<ICustomer> {
const { Contact } = this.tenancy.models(tenantId); const { Contact } = this.tenancy.models(tenantId);
@@ -46,7 +46,9 @@ export class CreateCustomer {
customerDTO customerDTO
); );
// Creates a new customer under unit-of-work envirement. // Creates a new customer under unit-of-work envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
// Triggers `onCustomerCreating` event. // Triggers `onCustomerCreating` event.
await this.eventPublisher.emitAsync(events.customers.onCreating, { await this.eventPublisher.emitAsync(events.customers.onCreating, {
tenantId, tenantId,
@@ -63,11 +65,12 @@ export class CreateCustomer {
customer, customer,
tenantId, tenantId,
customerId: customer.id, customerId: customer.id,
authorizedUser,
trx, trx,
} as ICustomerEventCreatedPayload); } as ICustomerEventCreatedPayload);
return customer; return customer;
}); },
trx
);
} }
} }

View File

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

View File

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

View File

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

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(), ).toMySqlDateTime(),
} }
: {}), : {}),
openingBalanceExchangeRate: defaultTo(
vendorDTO.openingBalanceExchangeRate,
1
),
}; };
}; };

View File

@@ -35,7 +35,7 @@ export class CreateVendor {
public async createVendor( public async createVendor(
tenantId: number, tenantId: number,
vendorDTO: IVendorNewDTO, vendorDTO: IVendorNewDTO,
authorizedUser: ISystemUser trx?: Knex.Transaction
) { ) {
const { Contact } = this.tenancy.models(tenantId); const { Contact } = this.tenancy.models(tenantId);
@@ -45,7 +45,9 @@ export class CreateVendor {
vendorDTO vendorDTO
); );
// Creates vendor contact under unit-of-work evnirement. // Creates vendor contact under unit-of-work evnirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
// Triggers `onVendorCreating` event. // Triggers `onVendorCreating` event.
await this.eventPublisher.emitAsync(events.vendors.onCreating, { await this.eventPublisher.emitAsync(events.vendors.onCreating, {
tenantId, tenantId,
@@ -62,11 +64,12 @@ export class CreateVendor {
tenantId, tenantId,
vendorId: vendor.id, vendorId: vendor.id,
vendor, vendor,
authorizedUser,
trx, trx,
} as IVendorEventCreatedPayload); } 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 { ImportFileDataValidator } from './ImportFileDataValidator';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { import {
ImportInsertError,
ImportOperError, ImportOperError,
ImportOperSuccess, ImportOperSuccess,
ImportableContext,
} from './interfaces'; } from './interfaces';
import { AccountsImportable } from '../Accounts/AccountsImportable';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { trimObject } from './_utils'; import { trimObject } from './_utils';
import { ImportableResources } from './ImportableResources'; import { ImportableResources } from './ImportableResources';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import Import from '@/models/Import';
@Service() @Service()
export class ImportFileCommon { export class ImportFileCommon {
@@ -39,12 +39,12 @@ export class ImportFileCommon {
* @returns {Record<string, any>[]} - The mapped data objects. * @returns {Record<string, any>[]} - The mapped data objects.
*/ */
public parseXlsxSheet(buffer: Buffer): Record<string, unknown>[] { 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 firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName]; const worksheet = workbook.Sheets[firstSheetName];
return XLSX.utils.sheet_to_json(worksheet); return XLSX.utils.sheet_to_json(worksheet, {});
} }
/** /**
@@ -66,16 +66,16 @@ export class ImportFileCommon {
*/ */
public async import( public async import(
tenantId: number, tenantId: number,
resourceName: string, importFile: Import,
parsedData: Record<string, any>[], parsedData: Record<string, any>[],
trx?: Knex.Transaction trx?: Knex.Transaction
): Promise<[ImportOperSuccess[], ImportOperError[]]> { ): Promise<[ImportOperSuccess[], ImportOperError[]]> {
const importableFields = this.resource.getResourceImportableFields( const importableFields = this.resource.getResourceImportableFields(
tenantId, tenantId,
resourceName importFile.resource
); );
const ImportableRegistry = this.importable.registry; const ImportableRegistry = this.importable.registry;
const importable = ImportableRegistry.getImportable(resourceName); const importable = ImportableRegistry.getImportable(importFile.resource);
const concurrency = importable.concurrency || 10; const concurrency = importable.concurrency || 10;
@@ -83,15 +83,25 @@ export class ImportFileCommon {
const failed: ImportOperError[] = []; const failed: ImportOperError[] = [];
const importAsync = async (objectDTO, index: number): Promise<void> => { const importAsync = async (objectDTO, index: number): Promise<void> => {
const context: ImportableContext = {
rowIndex: index,
import: importFile,
};
const transformedDTO = importable.transform(objectDTO, context);
try { try {
// Validate the DTO object before passing it to the service layer. // Validate the DTO object before passing it to the service layer.
await this.importFileValidator.validateData( await this.importFileValidator.validateData(
importableFields, importableFields,
objectDTO transformedDTO
); );
try { try {
// Run the importable function and listen to the errors. // 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 }); success.push({ index, data });
} catch (err) { } catch (err) {
if (err instanceof ServiceError) { if (err instanceof ServiceError) {
@@ -115,6 +125,60 @@ export class ImportFileCommon {
return [success, failed]; 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. * Retrieves the sheet columns from the given sheet data.
* @param {unknown[]} json * @param {unknown[]} json

View File

@@ -1,7 +1,11 @@
import { fromPairs } from 'lodash'; import { fromPairs } from 'lodash';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import { ImportFileMapPOJO, ImportMappingAttr } from './interfaces'; import {
ImportDateFormats,
ImportFileMapPOJO,
ImportMappingAttr,
} from './interfaces';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { ERRORS } from './_utils'; import { ERRORS } from './_utils';
@@ -37,12 +41,14 @@ export class ImportFileMapping {
// Validate the diplicated relations of map attrs. // Validate the diplicated relations of map attrs.
this.validateDuplicatedMapAttrs(maps); this.validateDuplicatedMapAttrs(maps);
// Validate the date format mapping.
this.validateDateFormatMapping(tenantId, importFile.resource, maps);
const mappingStringified = JSON.stringify(maps); const mappingStringified = JSON.stringify(maps);
await Import.query().findById(importFile.id).patch({ await Import.query().findById(importFile.id).patch({
mapping: mappingStringified, mapping: mappingStringified,
}); });
return { return {
import: { import: {
importId: importFile.importId, 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( const [successedImport, failedImport] = await this.uow.withTransaction(
tenantId, tenantId,
(trx: Knex.Transaction) => (trx: Knex.Transaction) =>
this.importCommon.import( this.importCommon.import(tenantId, importFile, parsedData, trx),
tenantId,
importFile.resource,
parsedData,
trx
),
trx trx
); );
const mapping = importFile.mappingParsed; const mapping = importFile.mappingParsed;

View File

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

View File

@@ -4,6 +4,8 @@ import { ImportFileMapping } from './ImportFileMapping';
import { ImportMappingAttr } from './interfaces'; import { ImportMappingAttr } from './interfaces';
import { ImportFileProcess } from './ImportFileProcess'; import { ImportFileProcess } from './ImportFileProcess';
import { ImportFilePreview } from './ImportFilePreview'; import { ImportFilePreview } from './ImportFilePreview';
import { ImportSampleService } from './ImportSample';
import { ImportFileMeta } from './ImportFileMeta';
@Inject() @Inject()
export class ImportResourceApplication { export class ImportResourceApplication {
@@ -19,25 +21,32 @@ export class ImportResourceApplication {
@Inject() @Inject()
private ImportFilePreviewService: ImportFilePreview; private ImportFilePreviewService: ImportFilePreview;
@Inject()
private importSampleService: ImportSampleService;
@Inject()
private importMetaService: ImportFileMeta;
/** /**
* Reads the imported file and stores the import file meta under unqiue id. * Reads the imported file and stores the import file meta under unqiue id.
* @param {number} tenantId - * @param {number} tenantId -
* @param {string} resource - * @param {string} resource - Resource name.
* @param {string} fileName - * @param {string} fileName - File name.
* @returns {Promise<ImportFileUploadPOJO>} * @returns {Promise<ImportFileUploadPOJO>}
*/ */
public async import( public async import(
tenantId: number, tenantId: number,
resource: string, 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. * Mapping the excel sheet columns with resource columns.
* @param {number} tenantId * @param {number} tenantId
* @param {number} importId * @param {number} importId - Import id.
* @param {ImportMappingAttr} maps * @param {ImportMappingAttr} maps
*/ */
public async mapping( public async mapping(
@@ -51,7 +60,7 @@ export class ImportResourceApplication {
/** /**
* Preview the mapped results before process importing. * Preview the mapped results before process importing.
* @param {number} tenantId * @param {number} tenantId
* @param {number} importId * @param {number} importId - Import id.
* @returns {Promise<ImportFilePreviewPOJO>} * @returns {Promise<ImportFilePreviewPOJO>}
*/ */
public async preview(tenantId: number, importId: number) { public async preview(tenantId: number, importId: number) {
@@ -67,4 +76,27 @@ export class ImportResourceApplication {
public async process(tenantId: number, importId: number) { public async process(tenantId: number, importId: number) {
return this.importProcessService.import(tenantId, importId); 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 { Knex } from 'knex';
import * as Yup from 'yup';
import { ImportableContext } from './interfaces';
export abstract class Importable { 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. * Concurrency controlling of the importing process.
* @returns {number} * @returns {number}
@@ -20,4 +32,41 @@ export abstract class Importable {
public get concurrency() { public get concurrency() {
return 10; 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 Container, { Service } from 'typedi';
import { AccountsImportable } from '../Accounts/AccountsImportable'; import { AccountsImportable } from '../Accounts/AccountsImportable';
import { ImportableRegistry } from './ImportableRegistry'; import { ImportableRegistry } from './ImportableRegistry';
import { UncategorizedTransactionsImportable } from '../Cashflow/UncategorizedTransactionsImportable';
import { CustomersImportable } from '../Contacts/Customers/CustomersImportable';
import { VendorsImportable } from '../Contacts/Vendors/VendorsImportable';
@Service() @Service()
export class ImportableResources { export class ImportableResources {
@@ -15,6 +18,12 @@ export class ImportableResources {
*/ */
private importables = [ private importables = [
{ resource: 'Account', importable: AccountsImportable }, { resource: 'Account', importable: AccountsImportable },
{
resource: 'UncategorizedCashflowTransaction',
importable: UncategorizedTransactionsImportable,
},
{ resource: 'Customer', importable: CustomersImportable },
{ resource: 'Vendor', importable: VendorsImportable },
]; ];
public get registry() { public get registry() {

View File

@@ -3,6 +3,17 @@ import { upperFirst, camelCase, first } from 'lodash';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { ResourceMetaFieldsMap } from './interfaces'; import { ResourceMetaFieldsMap } from './interfaces';
import { IModelMetaField } 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) { export function trimObject(obj) {
return Object.entries(obj).reduce((acc, [key, value]) => { return Object.entries(obj).reduce((acc, [key, value]) => {
@@ -47,6 +58,18 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
return acc; return acc;
}, {}); }, {});
fieldSchema = Yup.string().oneOf(Object.keys(options)).label(field.name); 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) { if (field.required) {
fieldSchema = fieldSchema.required(); fieldSchema = fieldSchema.required();
@@ -56,14 +79,6 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
return Yup.object().shape(yupSchema); 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) => { export const getUnmappedSheetColumns = (columns, mapping) => {
return columns.filter( return columns.filter(
(column) => !mapping.some((map) => map.from === column) (column) => !mapping.some((map) => map.from === column)

View File

@@ -1,8 +1,10 @@
import { IModelMetaField } from '@/interfaces'; import { IModelMetaField } from '@/interfaces';
import Import from '@/models/Import';
export interface ImportMappingAttr { export interface ImportMappingAttr {
from: string; from: string;
to: string; to: string;
dateFormat?: string;
} }
export interface ImportValidationError { export interface ImportValidationError {
@@ -59,3 +61,16 @@ export interface ImportOperError {
error: ImportInsertError; error: ImportInsertError;
index: number; 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, NavbarDivider,
Alignment, Alignment,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import { import {
Icon, Icon,
DashboardActionsBar, DashboardActionsBar,
@@ -48,6 +49,8 @@ function AccountTransactionsActionsBar({
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []); const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []); const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
const history = useHistory();
// Handle money in form // Handle money in form
const handleMoneyInFormTransaction = (account) => { const handleMoneyInFormTransaction = (account) => {
openDialog('money-in', { openDialog('money-in', {
@@ -64,6 +67,11 @@ function AccountTransactionsActionsBar({
account_name: account.name, account_name: account.name,
}); });
}; };
// Handle import button click.
const handleImportBtnClick = () => {
history.push(`/cashflow-accounts/${accountId}/import`);
};
// Refresh cashflow infinity transactions hook. // Refresh cashflow infinity transactions hook.
const { refresh } = useRefreshCashflowTransactionsInfinity(); const { refresh } = useRefreshCashflowTransactionsInfinity();
@@ -106,6 +114,7 @@ function AccountTransactionsActionsBar({
className={Classes.MINIMAL} className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />} icon={<Icon icon="file-import-16" iconSize={16} />}
text={<T id={'import'} />} text={<T id={'import'} />}
onClick={handleImportBtnClick}
/> />
<NavbarDivider /> <NavbarDivider />
<DashboardRowsHeightButton <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); addSetting('customers', 'tableSize', size);
}; };
// Handle import button click.
const handleImportBtnClick = () => {
history.push('/customers/import');
};
return ( return (
<DashboardActionsBar> <DashboardActionsBar>
<NavbarGroup> <NavbarGroup>
@@ -142,6 +147,7 @@ function CustomerActionsBar({
<Button <Button
className={Classes.MINIMAL} className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />} icon={<Icon icon="file-import-16" iconSize={16} />}
onClick={handleImportBtnClick}
text={<T id={'import'} />} text={<T id={'import'} />}
/> />
<Button <Button

View File

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

View File

@@ -7,11 +7,15 @@ import { ImportFileMappingForm } from './ImportFileMappingForm';
import { EntityColumn, useImportFileContext } from './ImportFileProvider'; import { EntityColumn, useImportFileContext } from './ImportFileProvider';
import { CLASSES } from '@/constants'; import { CLASSES } from '@/constants';
import { ImportFileContainer } from './ImportFileContainer'; import { ImportFileContainer } from './ImportFileContainer';
import styles from './ImportFileMapping.module.scss';
import { ImportStepperStep } from './_types'; import { ImportStepperStep } from './_types';
import { ImportFileMapBootProvider } from './ImportFileMappingBoot';
import styles from './ImportFileMapping.module.scss';
export function ImportFileMapping() { export function ImportFileMapping() {
const { importId } = useImportFileContext();
return ( return (
<ImportFileMapBootProvider importId={importId}>
<ImportFileMappingForm> <ImportFileMappingForm>
<ImportFileContainer> <ImportFileContainer>
<p> <p>
@@ -34,6 +38,7 @@ export function ImportFileMapping() {
<ImportFileMappingFloatingActions /> <ImportFileMappingFloatingActions />
</ImportFileMappingForm> </ImportFileMappingForm>
</ImportFileMapBootProvider>
); );
} }
@@ -67,6 +72,7 @@ function ImportFileMappingFields() {
</tr> </tr>
); );
const columns = entityColumns.map(columnMapper); const columns = entityColumns.map(columnMapper);
return <>{columns}</>; return <>{columns}</>;
} }
@@ -81,7 +87,7 @@ function ImportFileMappingFloatingActions() {
return ( return (
<div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}> <div className={clsx(CLASSES.PAGE_FORM_FLOATING_ACTIONS)}>
<Group spacing={10}> <Group spacing={10}>
<Button onClick={handleCancelBtnClick}>Cancel</Button> <Button onClick={handleCancelBtnClick}>Back</Button>
<Button type="submit" intent={Intent.PRIMARY} loading={isSubmitting}> <Button type="submit" intent={Intent.PRIMARY} loading={isSubmitting}>
Next Next
</Button> </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 { useMemo } from 'react';
import { isEmpty, lowerCase } from 'lodash'; import { isEmpty, lowerCase } from 'lodash';
import { AppToaster } from '@/components'; import { AppToaster } from '@/components';
import { useImportFileMapBootContext } from './ImportFileMappingBoot';
import { transformToForm } from '@/utils';
interface ImportFileMappingFormProps { interface ImportFileMappingFormProps {
children: React.ReactNode; children: React.ReactNode;
@@ -58,10 +60,23 @@ const transformValueToReq = (value: ImportFileMappingFormValues) => {
return { mapping }; return { mapping };
}; };
const transformResToFormValues = (value: { from: string; to: string }[]) => {
return value?.reduce((acc, map) => {
acc[map.to] = map.from;
return acc;
}, {});
};
const useImportFileMappingInitialValues = () => { const useImportFileMappingInitialValues = () => {
const { importFile } = useImportFileMapBootContext();
const { entityColumns, sheetColumns } = useImportFileContext(); const { entityColumns, sheetColumns } = useImportFileContext();
return useMemo( const initialResValues = useMemo(
() => transformResToFormValues(importFile?.map || []),
[importFile?.map],
);
const initialValues = useMemo(
() => () =>
entityColumns.reduce((acc, { key, name }) => { entityColumns.reduce((acc, { key, name }) => {
const _name = lowerCase(name); const _name = lowerCase(name);
@@ -75,4 +90,12 @@ const useImportFileMappingInitialValues = () => {
}, {}), }, {}),
[entityColumns, sheetColumns], [entityColumns, sheetColumns],
); );
return useMemo(
() => ({
...transformToForm(initialResValues, initialValues),
...initialValues,
}),
[initialValues, initialResValues],
);
}; };

View File

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

View File

@@ -33,12 +33,36 @@ interface ImportFileContextValue {
setImportId: Dispatch<SetStateAction<string>>; setImportId: Dispatch<SetStateAction<string>>;
resource: 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 { interface ImportFileProviderProps {
resource: string; resource: string;
description?: string;
params: Record<string, any>;
onImportSuccess?: () => void;
onImportFailed?: () => void;
onCancelClick?: () => void;
children: React.ReactNode; 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>( const ImportFileContext = createContext<ImportFileContextValue>(
{} as ImportFileContextValue, {} as ImportFileContextValue,
); );
@@ -57,6 +81,16 @@ export const useImportFileContext = () => {
export const ImportFileProvider = ({ export const ImportFileProvider = ({
resource, resource,
children, children,
description,
params,
onImportFailed,
onImportSuccess,
onCancelClick,
sampleFileName,
exampleDownload = true,
exampleTitle = ExampleTitle,
exampleDescription = ExampleDescription,
}: ImportFileProviderProps) => { }: ImportFileProviderProps) => {
const [sheetColumns, setSheetColumns] = useState<SheetColumn[]>([]); const [sheetColumns, setSheetColumns] = useState<SheetColumn[]>([]);
const [entityColumns, setEntityColumns] = useState<SheetColumn[]>([]); const [entityColumns, setEntityColumns] = useState<SheetColumn[]>([]);
@@ -82,6 +116,18 @@ export const ImportFileProvider = ({
setImportId, setImportId,
resource, resource,
description,
params,
onImportSuccess,
onImportFailed,
onCancelClick,
sampleFileName,
exampleDownload,
exampleTitle,
exampleDescription,
}; };
return ( return (

View File

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

View File

@@ -1,22 +1,31 @@
// @ts-nocheck // @ts-nocheck
import { Classes } from '@blueprintjs/core';
import { Stack } from '@/components'; import { Stack } from '@/components';
import { ImportDropzone } from './ImportDropzone'; import { ImportDropzone } from './ImportDropzone';
import { ImportSampleDownload } from './ImportSampleDownload'; import { ImportSampleDownload } from './ImportSampleDownload';
import { ImportFileUploadForm } from './ImportFileUploadForm'; import { ImportFileUploadForm } from './ImportFileUploadForm';
import { ImportFileUploadFooterActions } from './ImportFileFooterActions'; import { ImportFileUploadFooterActions } from './ImportFileFooterActions';
import { ImportFileContainer } from './ImportFileContainer'; import { ImportFileContainer } from './ImportFileContainer';
import { useImportFileContext } from './ImportFileProvider';
export function ImportFileUploadStep() { export function ImportFileUploadStep() {
const { exampleDownload } = useImportFileContext();
return ( return (
<ImportFileUploadForm> <ImportFileUploadForm>
<ImportFileContainer> <ImportFileContainer>
<p style={{ marginBottom: 18 }}> <p
Download a sample file and compare it to your import file to ensure className={Classes.TEXT_MUTED}
you have the file perfect for the import. 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> </p>
<Stack spacing={40}> <Stack spacing={40}>
<ImportDropzone /> <ImportDropzone />
<ImportSampleDownload /> {exampleDownload && <ImportSampleDownload />}
</Stack> </Stack>
</ImportFileContainer> </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 // @ts-nocheck
import { Box, Group } from '@/components'; import { AppToaster, Box, Group } from '@/components';
import { Button } from '@blueprintjs/core'; import {
Button,
Intent,
Menu,
MenuItem,
Popover,
PopoverInteractionKind,
} from '@blueprintjs/core';
import styles from './ImportSampleDownload.module.scss'; import styles from './ImportSampleDownload.module.scss';
import { useSampleSheetImport } from '@/hooks/query/import';
import { useImportFileContext } from './ImportFileProvider';
export function ImportSampleDownload() { 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 ( return (
<Group className={styles.root} noWrap> <Group className={styles.root} noWrap>
<Box> <Box>
<h3 className={styles.title}>Table Example</h3> <h3 className={styles.title}>{exampleTitle}</h3>
<p className={styles.description}> <p className={styles.description}>{exampleDescription}</p>
Download a sample file and compare it to your import file to ensure
you have the file perfect for the import.
</p>
</Box> </Box>
<Box className={styles.buttonWrap}> <Box className={styles.buttonWrap}>
<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> <Button minimal outlined>
Download File Download File
</Button> </Button>
</Popover>
</Box> </Box>
</Group> </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) => { const handleTableRowSizeChange = (size) => {
addSetting('vendors', 'tableSize', size); addSetting('vendors', 'tableSize', size);
}; };
// Handle import button success.
const handleImportBtnSuccess = () => {
history.push('/vendors/import');
};
return ( return (
<DashboardActionsBar> <DashboardActionsBar>
<NavbarGroup> <NavbarGroup>
@@ -128,6 +132,7 @@ function VendorActionsBar({
className={Classes.MINIMAL} className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />} icon={<Icon icon="file-import-16" iconSize={16} />}
text={<T id={'import'} />} text={<T id={'import'} />}
onClick={handleImportBtnSuccess}
/> />
<Button <Button
className={Classes.MINIMAL} className={Classes.MINIMAL}

View File

@@ -2,7 +2,12 @@
import { useMutation, useQuery, useQueryClient } from 'react-query'; import { useMutation, useQuery, useQueryClient } from 'react-query';
import useApiRequest from '../useRequest'; import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils'; 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) => { onSuccess: (res, id) => {
// Invalidate queries. // Invalidate queries.
queryClient.invalidateQueries([QueryKeys.ImportPreview]);
queryClient.invalidateQueries([QueryKeys.ImportFileMeta]);
}, },
...props, ...props,
}, },
); );
} }
export function useImportFilePreview(importId: string, props = {}) { export function useImportFilePreview(importId: string, props = {}) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const apiRequest = useApiRequest(); const apiRequest = useApiRequest();
return useQuery(['importPreview', importId], () => return useQuery([QueryKeys.ImportPreview, importId], () =>
apiRequest apiRequest
.get(`import/${importId}/preview`) .get(`import/${importId}/preview`)
.then((res) => transformToCamelCase(res.data)), .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 = { const SUBSCRIPTION_TYPE = {
MAIN: 'main', MAIN: 'main',
}; };
// const BASE_URL = '/dashboard';
export const getDashboardRoutes = () => [ export const getDashboardRoutes = () => [
// Accounts. // Accounts.
{ {
path: '/accounts/import', path: '/accounts/import',
component: lazy(() => import('@/containers/Import/ImportPage')), component: lazy(() => import('@/containers/Accounts/AccountsImport')),
breadcrumb: 'Accounts Import', breadcrumb: 'Accounts Import',
pageTitle: 'Accounts Import', pageTitle: 'Accounts Import',
}, },
@@ -77,6 +75,7 @@ export const getDashboardRoutes = () => [
}, },
// Items. // Items.
{ {
path: `/items/:id/edit`, path: `/items/:id/edit`,
component: lazy(() => import('@/containers/Items/ItemFormPage')), component: lazy(() => import('@/containers/Items/ItemFormPage')),
@@ -512,8 +511,20 @@ export const getDashboardRoutes = () => [
hotkey: 'shift+x', hotkey: 'shift+x',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
}, },
// Customers // 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`, path: `/customers/:id/edit`,
component: lazy( component: lazy(
@@ -564,6 +575,16 @@ export const getDashboardRoutes = () => [
}, },
// Vendors // 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`, path: `/vendors/:id/edit`,
component: lazy( component: lazy(
@@ -1031,6 +1052,19 @@ export const getDashboardRoutes = () => [
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
defaultSearchResource: RESOURCES_TYPES.ACCOUNT, 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`, path: `/cashflow-accounts`,
component: lazy( component: lazy(