diff --git a/packages/server/src/api/controllers/Accounts.ts b/packages/server/src/api/controllers/Accounts.ts index 0930a9014..d377b2b0a 100644 --- a/packages/server/src/api/controllers/Accounts.ts +++ b/packages/server/src/api/controllers/Accounts.ts @@ -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(), diff --git a/packages/server/src/api/controllers/Contacts/Customers.ts b/packages/server/src/api/controllers/Contacts/Customers.ts index b317a11d2..3615cb6dc 100644 --- a/packages/server/src/api/controllers/Contacts/Customers.ts +++ b/packages/server/src/api/controllers/Contacts/Customers.ts @@ -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), diff --git a/packages/server/src/api/controllers/Contacts/Vendors.ts b/packages/server/src/api/controllers/Contacts/Vendors.ts index 679719a0f..e02e9a2c4 100644 --- a/packages/server/src/api/controllers/Contacts/Vendors.ts +++ b/packages/server/src/api/controllers/Contacts/Vendors.ts @@ -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), diff --git a/packages/server/src/api/controllers/Import/ImportController.ts b/packages/server/src/api/controllers/Import/ImportController.ts index d8c44586c..645531f28 100644 --- a/packages/server/src/api/controllers/Import/ImportController.ts +++ b/packages/server/src/api/controllers/Import/ImportController.ts @@ -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); } } diff --git a/packages/server/src/database/migrations/20231209230719_create_imports_table.js b/packages/server/src/database/migrations/20231209230719_create_imports_table.js index 60fd5a83d..0a9556fd7 100644 --- a/packages/server/src/database/migrations/20231209230719_create_imports_table.js +++ b/packages/server/src/database/migrations/20231209230719_create_imports_table.js @@ -6,6 +6,7 @@ exports.up = function (knex) { table.string('resource'); table.json('columns'); table.json('mapping'); + table.json('params'); table.timestamps(); }); }; diff --git a/packages/server/src/models/Account.Settings.ts b/packages/server/src/models/Account.Settings.ts index c52f3c022..6b9fa341f 100644 --- a/packages/server/src/models/Account.Settings.ts +++ b/packages/server/src/models/Account.Settings.ts @@ -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, }, }, }; diff --git a/packages/server/src/models/Customer.Settings.ts b/packages/server/src/models/Customer.Settings.ts index 1d22941cf..53cf83296 100644 --- a/packages/server/src/models/Customer.Settings.ts +++ b/packages/server/src/models/Customer.Settings.ts @@ -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', }, }, }; diff --git a/packages/server/src/models/Import.ts b/packages/server/src/models/Import.ts index b0c558203..05b1c858c 100644 --- a/packages/server/src/models/Import.ts +++ b/packages/server/src/models/Import.ts @@ -1,8 +1,10 @@ import TenantModel from 'models/TenantModel'; export default class Import extends TenantModel { + resource!: string; mapping!: string; columns!: string; + params!: Record; /** * 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); diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.meta.ts b/packages/server/src/models/UncategorizedCashflowTransaction.meta.ts new file mode 100644 index 000000000..9d02576c1 --- /dev/null +++ b/packages/server/src/models/UncategorizedCashflowTransaction.meta.ts @@ -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, + }, + }, +}; diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index 928db9a4d..08c0975d4 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -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} diff --git a/packages/server/src/models/Vendor.Settings.ts b/packages/server/src/models/Vendor.Settings.ts index ba964edab..a1792ff0f 100644 --- a/packages/server/src/models/Vendor.Settings.ts +++ b/packages/server/src/models/Vendor.Settings.ts @@ -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', }, }, }; diff --git a/packages/server/src/services/Accounts/AccountsImportable.ts b/packages/server/src/services/Accounts/AccountsImportable.ts index 85429a751..93ab2cb8c 100644 --- a/packages/server/src/services/Accounts/AccountsImportable.ts +++ b/packages/server/src/services/Accounts/AccountsImportable.ts @@ -34,4 +34,12 @@ export class AccountsImportable extends Importable { public get concurrency() { return 1; } + + public public sampleData(): any[] { + return [ + { + + } + ] + } } diff --git a/packages/server/src/services/Cashflow/CreateUncategorizedTransaction.ts b/packages/server/src/services/Cashflow/CreateUncategorizedTransaction.ts index ccb2aca25..434722049 100644 --- a/packages/server/src/services/Cashflow/CreateUncategorizedTransaction.ts +++ b/packages/server/src/services/Cashflow/CreateUncategorizedTransaction.ts @@ -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 ); } } diff --git a/packages/server/src/services/Cashflow/UncategorizedTransactionsImportable.ts b/packages/server/src/services/Cashflow/UncategorizedTransactionsImportable.ts new file mode 100644 index 000000000..a34605842 --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionsImportable.ts @@ -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[]} + */ + public sampleData(): Record[] { + 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} params - + */ + public async validateParams( + tenantId: number, + params: Record + ): Promise { + const { Account } = this.tenancy.models(tenantId); + + if (params.accountId) { + await Account.query() + .findById(params.accountId) + .throwIfNotFound({}); + } + } +} \ No newline at end of file diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index bf448a549..dd66a17ed 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -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', + }, +] \ No newline at end of file diff --git a/packages/server/src/services/Contacts/Customers/CRUD/CreateCustomer.ts b/packages/server/src/services/Contacts/Customers/CRUD/CreateCustomer.ts index 6969360ff..aaa32f540 100644 --- a/packages/server/src/services/Contacts/Customers/CRUD/CreateCustomer.ts +++ b/packages/server/src/services/Contacts/Customers/CRUD/CreateCustomer.ts @@ -36,7 +36,7 @@ export class CreateCustomer { public async createCustomer( tenantId: number, customerDTO: ICustomerNewDTO, - authorizedUser: ISystemUser + trx?: Knex.Transaction ): Promise { 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 + ); } } diff --git a/packages/server/src/services/Contacts/Customers/CRUD/CreateEditCustomerDTO.ts b/packages/server/src/services/Contacts/Customers/CRUD/CreateEditCustomerDTO.ts index cdf8f8639..a9178e69a 100644 --- a/packages/server/src/services/Contacts/Customers/CRUD/CreateEditCustomerDTO.ts +++ b/packages/server/src/services/Contacts/Customers/CRUD/CreateEditCustomerDTO.ts @@ -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 + ), }; }; diff --git a/packages/server/src/services/Contacts/Customers/CRUD/EditCustomer.ts b/packages/server/src/services/Contacts/Customers/CRUD/EditCustomer.ts index 0b46936e1..252a6023c 100644 --- a/packages/server/src/services/Contacts/Customers/CRUD/EditCustomer.ts +++ b/packages/server/src/services/Contacts/Customers/CRUD/EditCustomer.ts @@ -4,7 +4,6 @@ import { ICustomerEditDTO, ICustomerEventEditedPayload, ICustomerEventEditingPayload, - ISystemUser, } from '@/interfaces'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import UnitOfWork from '@/services/UnitOfWork'; diff --git a/packages/server/src/services/Contacts/Customers/CustomersApplication.ts b/packages/server/src/services/Contacts/Customers/CustomersApplication.ts index 0bb080351..5edd4dccf 100644 --- a/packages/server/src/services/Contacts/Customers/CustomersApplication.ts +++ b/packages/server/src/services/Contacts/Customers/CustomersApplication.ts @@ -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); }; /** diff --git a/packages/server/src/services/Contacts/Customers/CustomersImportable.ts b/packages/server/src/services/Contacts/Customers/CustomersImportable.ts new file mode 100644 index 000000000..bb9d73ecc --- /dev/null +++ b/packages/server/src/services/Contacts/Customers/CustomersImportable.ts @@ -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} + */ + public async importable( + tenantId: number, + createDTO: ICustomerNewDTO, + trx?: Knex.Transaction + ): Promise { + await this.createCustomerService.createCustomer(tenantId, createDTO, trx); + } +} diff --git a/packages/server/src/services/Contacts/Vendors/CRUD/CreateEditVendorDTO.ts b/packages/server/src/services/Contacts/Vendors/CRUD/CreateEditVendorDTO.ts index b7a176b5b..69faa8d7f 100644 --- a/packages/server/src/services/Contacts/Vendors/CRUD/CreateEditVendorDTO.ts +++ b/packages/server/src/services/Contacts/Vendors/CRUD/CreateEditVendorDTO.ts @@ -49,6 +49,10 @@ export class CreateEditVendorDTO { ).toMySqlDateTime(), } : {}), + openingBalanceExchangeRate: defaultTo( + vendorDTO.openingBalanceExchangeRate, + 1 + ), }; }; diff --git a/packages/server/src/services/Contacts/Vendors/CRUD/CreateVendor.ts b/packages/server/src/services/Contacts/Vendors/CRUD/CreateVendor.ts index c720732aa..6e396ab25 100644 --- a/packages/server/src/services/Contacts/Vendors/CRUD/CreateVendor.ts +++ b/packages/server/src/services/Contacts/Vendors/CRUD/CreateVendor.ts @@ -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 + ); } } diff --git a/packages/server/src/services/Contacts/Vendors/VendorsImportable.ts b/packages/server/src/services/Contacts/Vendors/VendorsImportable.ts new file mode 100644 index 000000000..ce72b1ac7 --- /dev/null +++ b/packages/server/src/services/Contacts/Vendors/VendorsImportable.ts @@ -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 + ): Promise { + await this.createVendorService.createVendor(tenantId, createDTO, trx); + } +} diff --git a/packages/server/src/services/Import/ImportFileCommon.ts b/packages/server/src/services/Import/ImportFileCommon.ts index 86bfadf6c..90fe57310 100644 --- a/packages/server/src/services/Import/ImportFileCommon.ts +++ b/packages/server/src/services/Import/ImportFileCommon.ts @@ -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[]} - The mapped data objects. */ public parseXlsxSheet(buffer: Buffer): Record[] { - 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} parsedData - Parsed data. @@ -66,16 +66,16 @@ export class ImportFileCommon { */ public async import( tenantId: number, - resourceName: string, + importFile: Import, parsedData: Record[], 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 => { + 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} params + */ + public async validateParamsSchema( + resourceName: string, + params: Record + ) { + 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} params + */ + public async validateParams( + tenantId: number, + resourceName: string, + params: Record + ) { + const ImportableRegistry = this.importable.registry; + const importable = ImportableRegistry.getImportable(resourceName); + + await importable.validateParams(tenantId, params); + } + + /** + * + * @param {string} resourceName + * @param {Record} params + * @returns + */ + public transformParams(resourceName: string, params: Record) { + 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 diff --git a/packages/server/src/services/Import/ImportFileMapping.ts b/packages/server/src/services/Import/ImportFileMapping.ts index 1229c84f5..86b5e42cb 100644 --- a/packages/server/src/services/Import/ImportFileMapping.ts +++ b/packages/server/src/services/Import/ImportFileMapping.ts @@ -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); + } + } + }); + } } diff --git a/packages/server/src/services/Import/ImportFileMeta.ts b/packages/server/src/services/Import/ImportFileMeta.ts new file mode 100644 index 000000000..758f94fe4 --- /dev/null +++ b/packages/server/src/services/Import/ImportFileMeta.ts @@ -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() + ); + } +} diff --git a/packages/server/src/services/Import/ImportFileMetaTransformer.ts b/packages/server/src/services/Import/ImportFileMetaTransformer.ts new file mode 100644 index 000000000..6c50c1e37 --- /dev/null +++ b/packages/server/src/services/Import/ImportFileMetaTransformer.ts @@ -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; + } +} diff --git a/packages/server/src/services/Import/ImportFileProcess.ts b/packages/server/src/services/Import/ImportFileProcess.ts index ff95f35d6..a552f5ad8 100644 --- a/packages/server/src/services/Import/ImportFileProcess.ts +++ b/packages/server/src/services/Import/ImportFileProcess.ts @@ -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; diff --git a/packages/server/src/services/Import/ImportFileUpload.ts b/packages/server/src/services/Import/ImportFileUpload.ts index fc4a805eb..8dad9a53c 100644 --- a/packages/server/src/services/Import/ImportFileUpload.ts +++ b/packages/server/src/services/Import/ImportFileUpload.ts @@ -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 ): Promise { 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); diff --git a/packages/server/src/services/Import/ImportResourceApplication.ts b/packages/server/src/services/Import/ImportResourceApplication.ts index 8577a4b79..e4a7cb2f9 100644 --- a/packages/server/src/services/Import/ImportResourceApplication.ts +++ b/packages/server/src/services/Import/ImportResourceApplication.ts @@ -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} */ public async import( tenantId: number, resource: string, - filename: string + filename: string, + params: Record ) { - 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} */ 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); + } } diff --git a/packages/server/src/services/Import/ImportSample.ts b/packages/server/src/services/Import/ImportSample.ts new file mode 100644 index 000000000..aed610603 --- /dev/null +++ b/packages/server/src/services/Import/ImportSample.ts @@ -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; + } + } +} diff --git a/packages/server/src/services/Import/Importable.ts b/packages/server/src/services/Import/Importable.ts index 8130910f5..5be4f1fac 100644 --- a/packages/server/src/services/Import/Importable.ts +++ b/packages/server/src/services/Import/Importable.ts @@ -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} createDTO + * @param {ImportableContext} context + * @returns {Record} + */ + public transform(createDTO: Record, 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} + */ + public sampleData(): Array { + return []; + } + + // ------------------ + // # Params + // ------------------ + /** + * Params Yup validation schema. + * @returns {Yup.ObjectSchema} + */ + public paramsValidationSchema(): Yup.ObjectSchema { + return Yup.object().nullable(); + } + + /** + * Validates the params of the importable service. + * @param {Record} + * @returns {Promise} - True means passed and false failed. + */ + public async validateParams( + tenantId: number, + params: Record + ): Promise {} + + /** + * Transformes the import params before storing them. + * @param {Record} parmas + */ + public transformParams(parmas: Record) { + return parmas; + } } diff --git a/packages/server/src/services/Import/ImportableResources.ts b/packages/server/src/services/Import/ImportableResources.ts index 3f4297075..bbc5e3b4c 100644 --- a/packages/server/src/services/Import/ImportableResources.ts +++ b/packages/server/src/services/Import/ImportableResources.ts @@ -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() { diff --git a/packages/server/src/services/Import/_utils.ts b/packages/server/src/services/Import/_utils.ts index 08146c367..5e2e3a21c 100644 --- a/packages/server/src/services/Import/_utils.ts +++ b/packages/server/src/services/Import/_utils.ts @@ -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) diff --git a/packages/server/src/services/Import/interfaces.ts b/packages/server/src/services/Import/interfaces.ts index ab6bf5c38..69897086c 100644 --- a/packages/server/src/services/Import/interfaces.ts +++ b/packages/server/src/services/Import/interfaces.ts @@ -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' +] diff --git a/packages/server/src/utils/parse-json-safe.ts b/packages/server/src/utils/parse-json-safe.ts new file mode 100644 index 000000000..f8a12e2e7 --- /dev/null +++ b/packages/server/src/utils/parse-json-safe.ts @@ -0,0 +1,7 @@ +export const parseJsonSafe = (value: string) => { + try { + return JSON.parse(value); + } catch { + return null; + } +}; diff --git a/packages/webapp/src/containers/Accounts/AccountsImport.tsx b/packages/webapp/src/containers/Accounts/AccountsImport.tsx new file mode 100644 index 000000000..7941374a6 --- /dev/null +++ b/packages/webapp/src/containers/Accounts/AccountsImport.tsx @@ -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 ( + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index 2a1763b84..0bc4620a2 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -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={} text={} + onClick={handleImportBtnClick} /> { + history.push( + `/cashflow-accounts/${params.id}/transactions?filter=uncategorized`, + ); + }; + const handleCnacelBtnClick = () => { + history.push( + `/cashflow-accounts/${params.id}/transactions?filter=uncategorized`, + ); + }; + + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Customers/CustomersImport.tsx b/packages/webapp/src/containers/Customers/CustomersImport.tsx new file mode 100644 index 000000000..25877349a --- /dev/null +++ b/packages/webapp/src/containers/Customers/CustomersImport.tsx @@ -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 ( + + + + ); +} diff --git a/packages/webapp/src/containers/Customers/CustomersLanding/CustomersActionsBar.tsx b/packages/webapp/src/containers/Customers/CustomersLanding/CustomersActionsBar.tsx index 0afd96e5d..30f5ba3eb 100644 --- a/packages/webapp/src/containers/Customers/CustomersLanding/CustomersActionsBar.tsx +++ b/packages/webapp/src/containers/Customers/CustomersLanding/CustomersActionsBar.tsx @@ -95,6 +95,11 @@ function CustomerActionsBar({ addSetting('customers', 'tableSize', size); }; + // Handle import button click. + const handleImportBtnClick = () => { + history.push('/customers/import'); + }; + return ( @@ -142,6 +147,7 @@ function CustomerActionsBar({ + diff --git a/packages/webapp/src/containers/Import/ImportFileMappingBoot.tsx b/packages/webapp/src/containers/Import/ImportFileMappingBoot.tsx new file mode 100644 index 000000000..fb0b7f496 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileMappingBoot.tsx @@ -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( + {} as ImportFileMapBootContextValue, +); + +export const useImportFileMapBootContext = () => { + const context = useContext( + 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 ( + + {isImportFileLoading ? ( + + + + ) : ( + <>{children} + )} + + ); +}; diff --git a/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx b/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx index a518fe3ea..283e06e31 100644 --- a/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx +++ b/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx @@ -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], + ); }; diff --git a/packages/webapp/src/containers/Import/ImportFilePreview.tsx b/packages/webapp/src/containers/Import/ImportFilePreview.tsx index 552c75713..7bdf5c463 100644 --- a/packages/webapp/src/containers/Import/ImportFilePreview.tsx +++ b/packages/webapp/src/containers/Import/ImportFilePreview.tsx @@ -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); diff --git a/packages/webapp/src/containers/Import/ImportFileProvider.tsx b/packages/webapp/src/containers/Import/ImportFileProvider.tsx index 387c21f8c..3e7d0233f 100644 --- a/packages/webapp/src/containers/Import/ImportFileProvider.tsx +++ b/packages/webapp/src/containers/Import/ImportFileProvider.tsx @@ -33,12 +33,36 @@ interface ImportFileContextValue { setImportId: Dispatch>; resource: string; + description?: string; + params: Record; + onImportSuccess?: () => void; + onImportFailed?: () => void; + onCancelClick?: () => void; + sampleFileName?: string; + + exampleDownload?: boolean; + exampleTitle?: string; + exampleDescription?: string; } interface ImportFileProviderProps { resource: string; + description?: string; + params: Record; + 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( {} 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([]); const [entityColumns, setEntityColumns] = useState([]); @@ -82,6 +116,18 @@ export const ImportFileProvider = ({ setImportId, resource, + description, + params, + + onImportSuccess, + onImportFailed, + onCancelClick, + + sampleFileName, + + exampleDownload, + exampleTitle, + exampleDescription, }; return ( diff --git a/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx b/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx index 5eeafaa26..5ddf81bc5 100644 --- a/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx +++ b/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx @@ -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 }) => { diff --git a/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx b/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx index 04d126a78..b1c6f9e01 100644 --- a/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx +++ b/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx @@ -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 ( -

- Download a sample file and compare it to your import file to ensure - you have the file perfect for the import. +

+ 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.

+ - + {exampleDownload && }
diff --git a/packages/webapp/src/containers/Import/ImportPage.tsx b/packages/webapp/src/containers/Import/ImportPage.tsx deleted file mode 100644 index e12732b61..000000000 --- a/packages/webapp/src/containers/Import/ImportPage.tsx +++ /dev/null @@ -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 ( - - - - - - - - ); -} diff --git a/packages/webapp/src/containers/Import/ImportSampleDownload.tsx b/packages/webapp/src/containers/Import/ImportSampleDownload.tsx index 7d999f7f7..37a669b44 100644 --- a/packages/webapp/src/containers/Import/ImportSampleDownload.tsx +++ b/packages/webapp/src/containers/Import/ImportSampleDownload.tsx @@ -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 ( -

Table Example

-

- Download a sample file and compare it to your import file to ensure - you have the file perfect for the import. -

+

{exampleTitle}

+

{exampleDescription}

- + + + + + } + interactionKind={PopoverInteractionKind.CLICK} + placement="bottom-start" + minimal + > + +
); diff --git a/packages/webapp/src/containers/Import/ImportPage.module.scss b/packages/webapp/src/containers/Import/ImportView.module.scss similarity index 100% rename from packages/webapp/src/containers/Import/ImportPage.module.scss rename to packages/webapp/src/containers/Import/ImportView.module.scss diff --git a/packages/webapp/src/containers/Import/ImportView.tsx b/packages/webapp/src/containers/Import/ImportView.tsx new file mode 100644 index 000000000..24dcd9d97 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportView.tsx @@ -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; + onImportSuccess?: () => void; + onImportFailed?: () => void; + onCancelClick?: () => void; + sampleFileName?: string; + exampleDownload?: boolean; + exampleTitle?: string; + exampleDescription?: string; +} + +export function ImportView({ ...props }: ImportViewProps) { + return ( + + + + + + ); +} diff --git a/packages/webapp/src/containers/Import/index.ts b/packages/webapp/src/containers/Import/index.ts new file mode 100644 index 000000000..4696015d8 --- /dev/null +++ b/packages/webapp/src/containers/Import/index.ts @@ -0,0 +1 @@ +export * from './ImportView'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Items/ItemsImportable.tsx b/packages/webapp/src/containers/Items/ItemsImportable.tsx new file mode 100644 index 000000000..989bbdcaf --- /dev/null +++ b/packages/webapp/src/containers/Items/ItemsImportable.tsx @@ -0,0 +1,11 @@ +// @ts-nocheck +import { DashboardInsider } from '@/components'; +import { ImportView } from '../Import/ImportView'; + +export default function ItemsImport() { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Vendors/VendorsImport.tsx b/packages/webapp/src/containers/Vendors/VendorsImport.tsx new file mode 100644 index 000000000..8b4cf1714 --- /dev/null +++ b/packages/webapp/src/containers/Vendors/VendorsImport.tsx @@ -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 ( + + + + ); +} diff --git a/packages/webapp/src/containers/Vendors/VendorsLanding/VendorActionsBar.tsx b/packages/webapp/src/containers/Vendors/VendorsLanding/VendorActionsBar.tsx index 4fe9cd03b..bd54a6b16 100644 --- a/packages/webapp/src/containers/Vendors/VendorsLanding/VendorActionsBar.tsx +++ b/packages/webapp/src/containers/Vendors/VendorsLanding/VendorActionsBar.tsx @@ -83,6 +83,10 @@ function VendorActionsBar({ const handleTableRowSizeChange = (size) => { addSetting('vendors', 'tableSize', size); }; + // Handle import button success. + const handleImportBtnSuccess = () => { + history.push('/vendors/import'); + }; return ( @@ -128,6 +132,7 @@ function VendorActionsBar({ className={Classes.MINIMAL} icon={} text={} + onClick={handleImportBtnSuccess} />