diff --git a/packages/server/resources/locales/ar.json b/packages/server/resources/locales/ar.json index a52b13d51..5d51e1e82 100644 --- a/packages/server/resources/locales/ar.json +++ b/packages/server/resources/locales/ar.json @@ -242,7 +242,7 @@ "account.field.normal.credit": "دائن", "account.field.normal.debit": "مدين", "account.field.type": "نوع الحساب", - "account.field.active": "Activity", + "account.field.active": "Active", "account.field.balance": "الرصيد", "account.field.created_at": "أنشئت في", "item.field.type": "نوع الصنف", diff --git a/packages/server/resources/locales/en.json b/packages/server/resources/locales/en.json index 120aed816..c878c0576 100644 --- a/packages/server/resources/locales/en.json +++ b/packages/server/resources/locales/en.json @@ -241,7 +241,8 @@ "account.field.normal.credit": "Credit", "account.field.normal.debit": "Debit", "account.field.type": "Type", - "account.field.active": "Activity", + "account.field.active": "Active", + "account.field.currency": "Currency", "account.field.balance": "Balance", "account.field.created_at": "Created at", "item.field.type": "Item type", @@ -376,8 +377,8 @@ "customer.field.last_name": "Last name", "customer.field.display_name": "Display name", "customer.field.email": "Email", - "customer.field.work_phone": "Work phone", - "customer.field.personal_phone": "Personal phone", + "customer.field.work_phone": "Work Phone Number", + "customer.field.personal_phone": "Personal Phone Number", "customer.field.company_name": "Company name", "customer.field.website": "Website", "customer.field.opening_balance_at": "Opening balance at", @@ -385,7 +386,7 @@ "customer.field.created_at": "Created at", "customer.field.balance": "Balance", "customer.field.status": "Status", - "customer.field.currency": "Curreny", + "customer.field.currency": "Currency", "customer.field.status.active": "Active", "customer.field.status.inactive": "Inactive", "customer.field.status.overdue": "Overdue", @@ -394,8 +395,8 @@ "vendor.field.last_name": "Last name", "vendor.field.display_name": "Display name", "vendor.field.email": "Email", - "vendor.field.work_phone": "Work phone", - "vendor.field.personal_phone": "Personal phone", + "vendor.field.work_phone": "Work Phone Number", + "vendor.field.personal_phone": "Personal Phone Number", "vendor.field.company_name": "Company name", "vendor.field.website": "Website", "vendor.field.opening_balance_at": "Opening balance at", @@ -403,7 +404,7 @@ "vendor.field.created_at": "Created at", "vendor.field.balance": "Balance", "vendor.field.status": "Status", - "vendor.field.currency": "Curreny", + "vendor.field.currency": "Currency", "vendor.field.status.active": "Active", "vendor.field.status.inactive": "Inactive", "vendor.field.status.overdue": "Overdue", diff --git a/packages/server/src/api/controllers/Accounts.ts b/packages/server/src/api/controllers/Accounts.ts index 297201ae7..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(), @@ -349,7 +349,7 @@ export default class AccountsController extends BaseController { // Filter query. const filter = { sortOrder: 'desc', - columnSortBy: 'created_at', + columnSortBy: 'createdAt', inactiveMode: false, structure: IAccountsStructureType.Tree, ...this.matchedQueryData(req), 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 c2d90cbe2..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,7 +44,18 @@ export class ImportController extends BaseController { this.asyncMiddleware(this.mapping.bind(this)), this.catchServiceErrors ); - router.post( + 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)), this.catchServiceErrors @@ -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/api/controllers/Import/_utils.ts b/packages/server/src/api/controllers/Import/_utils.ts index 333e280fe..0621ba999 100644 --- a/packages/server/src/api/controllers/Import/_utils.ts +++ b/packages/server/src/api/controllers/Import/_utils.ts @@ -4,7 +4,8 @@ import { ServiceError } from '@/exceptions'; export function allowSheetExtensions(req, file, cb) { if ( file.mimetype !== 'text/csv' && - file.mimetype !== 'application/vnd.ms-excel' + file.mimetype !== 'application/vnd.ms-excel' && + file.mimetype !== 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) { cb(new ServiceError('IMPORTED_FILE_EXTENSION_INVALID')); 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/interfaces/Model.ts b/packages/server/src/interfaces/Model.ts index 385bce172..c54d05f38 100644 --- a/packages/server/src/interfaces/Model.ts +++ b/packages/server/src/interfaces/Model.ts @@ -35,20 +35,40 @@ export interface IModelMetaFieldCommon { fieldType: IModelColumnType; customQuery?: Function; required?: boolean; + importHint?: string; + order?: number; + unique?: number; } -export interface IModelMetaFieldNumber { - fieldType: 'number'; +export interface IModelMetaFieldText { + fieldType: 'text'; minLength?: number; maxLength?: number; } - -export interface IModelMetaFieldOther { - fieldType: 'text' | 'boolean'; +export interface IModelMetaFieldBoolean { + fieldType: 'boolean'; +} +export interface IModelMetaFieldNumber { + fieldType: 'number'; + min?: number; + max?: number; +} +export interface IModelMetaFieldDate { + fieldType: 'date'; +} +export interface IModelMetaFieldUrl { + fieldType: 'url'; } - export type IModelMetaField = IModelMetaFieldCommon & - (IModelMetaFieldOther | IModelMetaEnumerationField | IModelMetaRelationField); + ( + | IModelMetaFieldText + | IModelMetaFieldNumber + | IModelMetaFieldBoolean + | IModelMetaFieldDate + | IModelMetaFieldUrl + | IModelMetaEnumerationField + | IModelMetaRelationField + ); export interface IModelMetaEnumerationOption { key: string; @@ -71,9 +91,8 @@ export interface IModelMetaRelationEnumerationField { relationEntityKey: string; } -export type IModelMetaRelationField = IModelMetaRelationFieldCommon & ( - IModelMetaRelationEnumerationField -); +export type IModelMetaRelationField = IModelMetaRelationFieldCommon & + IModelMetaRelationEnumerationField; export interface IModelMeta { defaultFilterField: string; diff --git a/packages/server/src/models/Account.Settings.ts b/packages/server/src/models/Account.Settings.ts index ec67885c4..8d82ac1de 100644 --- a/packages/server/src/models/Account.Settings.ts +++ b/packages/server/src/models/Account.Settings.ts @@ -15,12 +15,15 @@ export default { unique: true, required: true, importable: true, + exportable: true, + order: 1, }, description: { name: 'account.field.description', column: 'description', fieldType: 'text', importable: true, + exportable: true, }, slug: { name: 'account.field.slug', @@ -34,9 +37,12 @@ export default { name: 'account.field.code', column: 'code', fieldType: 'text', + exportable: true, importable: true, minLength: 3, maxLength: 6, + unique: true, + importHint: 'Unique number to identify the account.', }, rootType: { name: 'account.field.root_type', @@ -73,19 +79,22 @@ export default { })), required: true, importable: true, + exportable: true, + order: 2, }, active: { name: 'account.field.active', column: 'active', fieldType: 'boolean', filterable: false, + exportable: true, importable: true, }, - openingBalance: { + balance: { name: 'account.field.balance', column: 'amount', fieldType: 'number', - importable: true, + importable: false, }, currencyCode: { name: 'account.field.currency', @@ -93,18 +102,21 @@ export default { fieldType: 'text', filterable: false, importable: true, + exportable: true, }, parentAccount: { name: 'account.field.parent_account', column: 'parent_account_id', fieldType: 'relation', to: { model: 'Account', to: 'id' }, + importable: false, }, createdAt: { name: 'account.field.created_at', 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..b789c9adf 100644 --- a/packages/server/src/models/Customer.Settings.ts +++ b/packages/server/src/models/Customer.Settings.ts @@ -1,70 +1,112 @@ export default { + importable: true, + defaultFilterField: 'displayName', + defaultSort: { + sortOrder: 'DESC', + sortField: 'createdAt', + }, fields: { - first_name: { + customerType: { + name: 'Customer Type', + column: 'contact_type', + fieldType: 'enumeration', + options: [ + { key: 'business', label: 'Business' }, + { key: 'individual', label: 'Individual' }, + ], + importable: true, + required: true, + }, + 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', + fieldType: 'url', + 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, + }, + openingBalanceExchangeRate: { + name: 'Opening Balance Ex. Rate', + column: 'opening_balance_exchange_rate', + fieldType: 'number', + importable: true, }, - currency_code: { + currencyCode: { name: 'customer.field.currency', column: 'currency_code', fieldType: 'text', + importable: true, + }, + note: { + name: 'Note', + column: 'note', + fieldType: 'text', + importable: true, + }, + active: { + name: 'Active', + column: 'active', + fieldType: 'boolean', + importable: true, }, status: { name: 'customer.field.status', @@ -77,6 +119,98 @@ export default { ], filterCustomQuery: statusFieldFilterQuery, }, + // 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/Item.Settings.ts b/packages/server/src/models/Item.Settings.ts index b5509a0a4..3ff5b5613 100644 --- a/packages/server/src/models/Item.Settings.ts +++ b/packages/server/src/models/Item.Settings.ts @@ -1,51 +1,59 @@ export default { + importable: true, defaultFilterField: 'name', defaultSort: { sortField: 'name', sortOrder: 'DESC', }, fields: { - 'type': { + type: { name: 'item.field.type', column: 'type', fieldType: 'enumeration', options: [ - { key: 'inventory', label: 'item.field.type.inventory', }, + { key: 'inventory', label: 'item.field.type.inventory' }, { key: 'service', label: 'item.field.type.service' }, - { key: 'non-inventory', label: 'item.field.type.non-inventory', }, + { key: 'non-inventory', label: 'item.field.type.non-inventory' }, ], + importable: true, }, - 'name': { + name: { name: 'item.field.name', column: 'name', fieldType: 'text', + importable: true, }, - 'code': { + code: { name: 'item.field.code', column: 'code', fieldType: 'text', + importable: true, }, - 'sellable': { + sellable: { name: 'item.field.sellable', column: 'sellable', fieldType: 'boolean', + importable: true, }, - 'purchasable': { + purchasable: { name: 'item.field.purchasable', column: 'purchasable', fieldType: 'boolean', + importable: true, }, - 'sell_price': { + sellPrice: { name: 'item.field.cost_price', column: 'sell_price', fieldType: 'number', + importable: true, }, - 'cost_price': { + costPrice: { name: 'item.field.cost_account', column: 'cost_price', fieldType: 'number', + importable: true, }, - 'cost_account': { + costAccount: { name: 'item.field.sell_account', column: 'cost_account_id', fieldType: 'relation', @@ -55,8 +63,10 @@ export default { relationEntityLabel: 'name', relationEntityKey: 'slug', + + importable: true, }, - 'sell_account': { + sellAccount: { name: 'item.field.sell_description', column: 'sell_account_id', fieldType: 'relation', @@ -66,8 +76,10 @@ export default { relationEntityLabel: 'name', relationEntityKey: 'slug', + + importable: true, }, - 'inventory_account': { + inventoryAccount: { name: 'item.field.inventory_account', column: 'inventory_account_id', @@ -76,28 +88,34 @@ export default { relationEntityLabel: 'name', relationEntityKey: 'slug', + + importable: true, }, - 'sell_description': { + sellDescription: { name: 'Sell description', column: 'sell_description', fieldType: 'text', + importable: true, }, - 'purchase_description': { + purchaseDescription: { name: 'Purchase description', column: 'purchase_description', fieldType: 'text', + importable: true, }, - 'quantity_on_hand': { + quantityOnHand: { name: 'item.field.quantity_on_hand', column: 'quantity_on_hand', fieldType: 'number', + importable: true, }, - 'note': { + note: { name: 'item.field.note', column: 'note', fieldType: 'text', + importable: true, }, - 'category': { + category: { name: 'item.field.category', column: 'category_id', @@ -106,14 +124,15 @@ export default { relationEntityLabel: 'name', relationEntityKey: 'id', + importable: true, }, - 'active': { + active: { name: 'item.field.active', column: 'active', fieldType: 'boolean', - filterable: false, + importable: true, }, - 'created_at': { + createdAt: { name: 'item.field.created_at', column: 'created_at', columnType: 'date', 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..0317d43c5 100644 --- a/packages/server/src/models/Vendor.Settings.ts +++ b/packages/server/src/models/Vendor.Settings.ts @@ -1,74 +1,100 @@ 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: { - name: 'vendor.field.personal_pone', + personalPhone: { + name: 'vendor.field.personal_phone', 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, + }, + openingBalanceExchangeRate: { + name: 'Opening Balance Ex. Rate', + column: 'opening_balance_exchange_rate', + fieldType: 'number', + importable: true, }, - currency_code: { + currencyCode: { name: 'vendor.field.currency', column: 'currency_code', fieldType: 'text', + importable: true, + }, + note: { + name: 'Note', + column: 'note', + fieldType: 'text', + importable: true, + }, + active: { + name: 'Active', + column: 'active', + fieldType: 'boolean', + importable: true, }, status: { name: 'vendor.field.status', @@ -88,5 +114,96 @@ export default { } }, }, + // 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.SampleData.ts b/packages/server/src/services/Accounts/AccountsImportable.SampleData.ts new file mode 100644 index 000000000..1757bd498 --- /dev/null +++ b/packages/server/src/services/Accounts/AccountsImportable.SampleData.ts @@ -0,0 +1,50 @@ +export const AccountsSampleData = [ + { + 'Account Name': 'Utilities Expense', + 'Account Code': 9000, + Type: 'Expense', + Description: 'Omnis voluptatum consequatur.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Unearned Revenue', + 'Account Code': 9010, + Type: 'Long Term Liability', + Description: 'Autem odit voluptas nihil unde.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Long-Term Debt', + 'Account Code': 9020, + Type: 'Long Term Liability', + Description: 'In voluptas cumque exercitationem.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Salaries and Wages Expense', + 'Account Code': 9030, + Type: 'Expense', + Description: 'Assumenda aspernatur soluta aliquid perspiciatis quasi.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Rental Income', + 'Account Code': 9040, + Type: 'Income', + Description: 'Omnis possimus amet occaecati inventore.', + Active: 'T', + 'Currency Code': '', + }, + { + 'Account Name': 'Paypal', + 'Account Code': 9050, + Type: 'Bank', + Description: 'In voluptas cumque exercitationem.', + Active: 'T', + 'Currency Code': '', + }, +]; diff --git a/packages/server/src/services/Accounts/AccountsImportable.ts b/packages/server/src/services/Accounts/AccountsImportable.ts index 85429a751..28a7a18a3 100644 --- a/packages/server/src/services/Accounts/AccountsImportable.ts +++ b/packages/server/src/services/Accounts/AccountsImportable.ts @@ -3,6 +3,7 @@ import { Knex } from 'knex'; import { IAccountCreateDTO } from '@/interfaces'; import { CreateAccount } from './CreateAccount'; import { Importable } from '../Import/Importable'; +import { AccountsSampleData } from './AccountsImportable.SampleData'; @Service() export class AccountsImportable extends Importable { @@ -34,4 +35,11 @@ export class AccountsImportable extends Importable { public get concurrency() { return 1; } + + /** + * Retrieves the sample data that used to download accounts sample sheet. + */ + public sampleData(): any[] { + return AccountsSampleData; + } } diff --git a/packages/server/src/services/Accounts/CommandAccountValidators.ts b/packages/server/src/services/Accounts/CommandAccountValidators.ts index 2819c7fdd..ed4d7ffbd 100644 --- a/packages/server/src/services/Accounts/CommandAccountValidators.ts +++ b/packages/server/src/services/Accounts/CommandAccountValidators.ts @@ -97,9 +97,11 @@ export class CommandAccountValidators { query.whereNot('id', notAccountId); } }); - if (account.length > 0) { - throw new ServiceError(ERRORS.ACCOUNT_CODE_NOT_UNIQUE); + throw new ServiceError( + ERRORS.ACCOUNT_CODE_NOT_UNIQUE, + 'Account code is not unique.' + ); } } @@ -124,7 +126,10 @@ export class CommandAccountValidators { } }); if (foundAccount) { - throw new ServiceError(ERRORS.ACCOUNT_NAME_NOT_UNIQUE); + throw new ServiceError( + ERRORS.ACCOUNT_NAME_NOT_UNIQUE, + 'Account name is not unique.' + ); } } diff --git a/packages/server/src/services/Cashflow/CashflowDeleteAccount.ts b/packages/server/src/services/Cashflow/CashflowDeleteAccount.ts index 1c15672c7..bdc90641a 100644 --- a/packages/server/src/services/Cashflow/CashflowDeleteAccount.ts +++ b/packages/server/src/services/Cashflow/CashflowDeleteAccount.ts @@ -6,7 +6,7 @@ import { ERRORS } from './constants'; @Service() export default class CashflowDeleteAccount { @Inject() - tenancy: HasTenancyService; + private tenancy: HasTenancyService; /** * Validate the account has no associated cashflow transactions. 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..a08a27f07 --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionsImportable.ts @@ -0,0 +1,82 @@ +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 { BankTransactionsSampleData } 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 BankTransactionsSampleData; + } + + /** + * 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({}); + } + } +} diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index bf448a549..c77c45f69 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -11,8 +11,10 @@ export const ERRORS = { ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions', TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED', TRANSACTION_ALREADY_UNCATEGORIZED: 'TRANSACTION_ALREADY_UNCATEGORIZED', - UNCATEGORIZED_TRANSACTION_TYPE_INVALID: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', - CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED: 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED' + UNCATEGORIZED_TRANSACTION_TYPE_INVALID: + 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', + CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED: + 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED', }; export enum CASHFLOW_DIRECTION { @@ -75,3 +77,29 @@ export interface ICashflowTransactionTypeMeta { direction: CASHFLOW_DIRECTION; creditType: string[]; } + +export const BankTransactionsSampleData = [ + [ + { + Amount: '6,410.19', + Date: '2024-03-26', + Payee: 'MacGyver and Sons', + 'Reference No.': 'REF-1', + Description: 'Commodi quo labore.', + }, + { + Amount: '8,914.17', + Date: '2024-01-05', + Payee: 'Eichmann - Bergnaum', + 'Reference No.': 'REF-1', + Description: 'Quia enim et.', + }, + { + Amount: '6,200.88', + Date: '2024-02-17', + Payee: 'Luettgen, Mraz and Legros', + 'Reference No.': 'REF-1', + Description: 'Occaecati consequuntur cum impedit illo.', + }, + ], +]; 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..599cfaf83 --- /dev/null +++ b/packages/server/src/services/Contacts/Customers/CustomersImportable.ts @@ -0,0 +1,34 @@ +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'; +import { CustomersSampleData } from './_SampleData'; + +@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); + } + + /** + * Retrieves the sample data of customers used to download sample sheet. + */ + public sampleData(): any[] { + return CustomersSampleData; + } +} diff --git a/packages/server/src/services/Contacts/Customers/_SampleData.ts b/packages/server/src/services/Contacts/Customers/_SampleData.ts new file mode 100644 index 000000000..601d845d5 --- /dev/null +++ b/packages/server/src/services/Contacts/Customers/_SampleData.ts @@ -0,0 +1,158 @@ + +export const CustomersSampleData = [ + { + "Customer Type": "Business", + "First Name": "Nicolette", + "Last Name": "Schamberger", + "Company Name": "Homenick - Hane", + "Display Name": "Rowland Rowe", + "Email": "cicero86@yahoo.com", + "Personal Phone Number": "811-603-2235", + "Work Phone Number": "906-993-5190", + "Website": "http://google.com", + "Opening Balance": 54302.23, + "Opening Balance At": "2022-02-02", + "Opening Balance Ex. Rate": 2, + "Currency": "LYD", + "Active": "F", + "Note": "Doloribus autem optio temporibus dolores mollitia sit.", + "Billing Address 1": "862 Jessika Well", + "Billing Address 2": "1091 Dorthy Mount", + "Billing Address City": "Deckowfort", + "Billing Address Country": "Ghana", + "Billing Address Phone": "825-011-5207", + "Billing Address Postcode": "38228", + "Billing Address State": "Oregon", + "Shipping Address 1": "37626 Thiel Villages", + "Shipping Address 2": "132 Batz Avenue", + "Shipping Address City": "Pagacburgh", + "Shipping Address Country": "Albania", + "Shipping Address Phone": "171-546-3701", + "Shipping Address Postcode": "13709", + "Shipping Address State": "Georgia" + }, + { + "Customer Type": "Business", + "First Name": "Hermann", + "Last Name": "Crooks", + "Company Name": "Veum - Schaefer", + "Display Name": "Harley Veum", + "Email": "immanuel56@hotmail.com", + "Personal Phone Number": "449-780-9999", + "Work Phone Number": "970-473-5785", + "Website": "http://google.com", + "Opening Balance": 54302.23, + "Opening Balance At": "2022-02-02", + "Opening Balance Ex. Rate": 2, + "Currency": "LYD", + "Active": "T", + "Note": "Doloribus dolore dolor dicta vitae in fugit nisi quibusdam.", + "Billing Address 1": "532 Simonis Spring", + "Billing Address 2": "3122 Nicolas Inlet", + "Billing Address City": "East Matteofort", + "Billing Address Country": "Holy See (Vatican City State)", + "Billing Address Phone": "366-084-8629", + "Billing Address Postcode": "41607", + "Billing Address State": "Montana", + "Shipping Address 1": "2889 Tremblay Plaza", + "Shipping Address 2": "71355 Kutch Isle", + "Shipping Address City": "D'Amorehaven", + "Shipping Address Country": "Monaco", + "Shipping Address Phone": "614-189-3328", + "Shipping Address Postcode": "09634-0435", + "Shipping Address State": "Nevada" + }, + { + "Customer Type": "Business", + "First Name": "Nellie", + "Last Name": "Gulgowski", + "Company Name": "Boyle, Heller and Jones", + "Display Name": "Randall Kohler", + "Email": "anibal_frami@yahoo.com", + "Personal Phone Number": "498-578-0740", + "Work Phone Number": "394-550-6827", + "Website": "http://google.com", + "Opening Balance": 54302.23, + "Opening Balance At": "2022-02-02", + "Opening Balance Ex. Rate": 2, + "Currency": "LYD", + "Active": "T", + "Note": "Vero quibusdam rem fugit aperiam est modi.", + "Billing Address 1": "214 Sauer Villages", + "Billing Address 2": "30687 Kacey Square", + "Billing Address City": "Jayceborough", + "Billing Address Country": "Benin", + "Billing Address Phone": "332-820-1127", + "Billing Address Postcode": "16425-3887", + "Billing Address State": "Mississippi", + "Shipping Address 1": "562 Diamond Loaf", + "Shipping Address 2": "9595 Satterfield Trafficway", + "Shipping Address City": "Alexandrinefort", + "Shipping Address Country": "Puerto Rico", + "Shipping Address Phone": "776-500-8456", + "Shipping Address Postcode": "30258", + "Shipping Address State": "South Dakota" + }, + { + "Customer Type": "Business", + "First Name": "Stone", + "Last Name": "Jerde", + "Company Name": "Cassin, Casper and Maggio", + "Display Name": "Clint McLaughlin", + "Email": "nathanael22@yahoo.com", + "Personal Phone Number": "562-790-6059", + "Work Phone Number": "686-838-0027", + "Website": "http://google.com", + "Opening Balance": 54302.23, + "Opening Balance At": "2022-02-02", + "Opening Balance Ex. Rate": 2, + "Currency": "LYD", + "Active": "F", + "Note": "Quis cumque molestias rerum.", + "Billing Address 1": "22590 Cathy Harbor", + "Billing Address 2": "24493 Brycen Brooks", + "Billing Address City": "Elnorashire", + "Billing Address Country": "Andorra", + "Billing Address Phone": "701-852-8005", + "Billing Address Postcode": "5680", + "Billing Address State": "Nevada", + "Shipping Address 1": "5355 Erdman Bridge", + "Shipping Address 2": "421 Jeanette Camp", + "Shipping Address City": "East Philip", + "Shipping Address Country": "Venezuela", + "Shipping Address Phone": "426-119-0858", + "Shipping Address Postcode": "34929-0501", + "Shipping Address State": "Tennessee" + }, + { + "Customer Type": "Individual", + "First Name": "Lempi", + "Last Name": "Kling", + "Company Name": "Schamberger, O'Connell and Bechtelar", + "Display Name": "Alexie Barton", + "Email": "eulah.kreiger@hotmail.com", + "Personal Phone Number": "745-756-1063", + "Work Phone Number": "965-150-1945", + "Website": "http://google.com", + "Opening Balance": 54302.23, + "Opening Balance At": "2022-02-02", + "Opening Balance Ex. Rate": 2, + "Currency": "LYD", + "Active": "F", + "Note": "Maxime laboriosam hic voluptate maiores est officia.", + "Billing Address 1": "0851 Jones Flat", + "Billing Address 2": "845 Bailee Drives", + "Billing Address City": "Kamrenport", + "Billing Address Country": "Niger", + "Billing Address Phone": "220-125-0608", + "Billing Address Postcode": "30311", + "Billing Address State": "Delaware", + "Shipping Address 1": "929 Ferry Row", + "Shipping Address 2": "020 Adam Plaza", + "Shipping Address City": "West Carmellaside", + "Shipping Address Country": "Ghana", + "Shipping Address Phone": "053-333-6679", + "Shipping Address Postcode": "79221-4681", + "Shipping Address State": "Illinois" + } +] 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..67b4a57af --- /dev/null +++ b/packages/server/src/services/Contacts/Vendors/VendorsImportable.ts @@ -0,0 +1,32 @@ +import { Importable } from '@/services/Import/Importable'; +import { CreateVendor } from './CRUD/CreateVendor'; +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { VendorsSampleData } from './_SampleData'; + +@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); + } + + /** + * Retrieves the sample data of vendors sample sheet. + */ + public sampleData(): any[] { + return VendorsSampleData; + } +} diff --git a/packages/server/src/services/Contacts/Vendors/_SampleData.ts b/packages/server/src/services/Contacts/Vendors/_SampleData.ts new file mode 100644 index 000000000..dc88d71f9 --- /dev/null +++ b/packages/server/src/services/Contacts/Vendors/_SampleData.ts @@ -0,0 +1,122 @@ +export const VendorsSampleData = [ + { + "First Name": "Nicolette", + "Last Name": "Schamberger", + "Company Name": "Homenick - Hane", + "Display Name": "Rowland Rowe", + "Email": "cicero86@yahoo.com", + "Personal Phone Number": "811-603-2235", + "Work Phone Number": "906-993-5190", + "Website": "http://google.com", + "Opening Balance": 54302.23, + "Opening Balance At": "2022-02-02", + "Opening Balance Ex. Rate": 2, + "Currency": "LYD", + "Active": "F", + "Note": "Doloribus autem optio temporibus dolores mollitia sit.", + "Billing Address 1": "862 Jessika Well", + "Billing Address 2": "1091 Dorthy Mount", + "Billing Address City": "Deckowfort", + "Billing Address Country": "Ghana", + "Billing Address Phone": "825-011-5207", + "Billing Address Postcode": "38228", + "Billing Address State": "Oregon", + "Shipping Address 1": "37626 Thiel Villages", + "Shipping Address 2": "132 Batz Avenue", + "Shipping Address City": "Pagacburgh", + "Shipping Address Country": "Albania", + "Shipping Address Phone": "171-546-3701", + "Shipping Address Postcode": "13709", + "Shipping Address State": "Georgia" + }, + { + "First Name": "Hermann", + "Last Name": "Crooks", + "Company Name": "Veum - Schaefer", + "Display Name": "Harley Veum", + "Email": "immanuel56@hotmail.com", + "Personal Phone Number": "449-780-9999", + "Work Phone Number": "970-473-5785", + "Website": "http://google.com", + "Opening Balance": 54302.23, + "Opening Balance At": "2022-02-02", + "Opening Balance Ex. Rate": 2, + "Currency": "LYD", + "Active": "F", + "Note": "Doloribus dolore dolor dicta vitae in fugit nisi quibusdam.", + "Billing Address 1": "532 Simonis Spring", + "Billing Address 2": "3122 Nicolas Inlet", + "Billing Address City": "East Matteofort", + "Billing Address Country": "Holy See (Vatican City State)", + "Billing Address Phone": "366-084-8629", + "Billing Address Postcode": "41607", + "Billing Address State": "Montana", + "Shipping Address 1": "2889 Tremblay Plaza", + "Shipping Address 2": "71355 Kutch Isle", + "Shipping Address City": "D'Amorehaven", + "Shipping Address Country": "Monaco", + "Shipping Address Phone": "614-189-3328", + "Shipping Address Postcode": "09634-0435", + "Shipping Address State": "Nevada" + }, + { + "First Name": "Nellie", + "Last Name": "Gulgowski", + "Company Name": "Boyle, Heller and Jones", + "Display Name": "Randall Kohler", + "Email": "anibal_frami@yahoo.com", + "Personal Phone Number": "498-578-0740", + "Work Phone Number": "394-550-6827", + "Website": "http://google.com", + "Opening Balance": 54302.23, + "Opening Balance At": "2022-02-02", + "Opening Balance Ex. Rate": 2, + "Currency": "LYD", + "Active": "F", + "Note": "Vero quibusdam rem fugit aperiam est modi.", + "Billing Address 1": "214 Sauer Villages", + "Billing Address 2": "30687 Kacey Square", + "Billing Address City": "Jayceborough", + "Billing Address Country": "Benin", + "Billing Address Phone": "332-820-1127", + "Billing Address Postcode": "16425-3887", + "Billing Address State": "Mississippi", + "Shipping Address 1": "562 Diamond Loaf", + "Shipping Address 2": "9595 Satterfield Trafficway", + "Shipping Address City": "Alexandrinefort", + "Shipping Address Country": "Puerto Rico", + "Shipping Address Phone": "776-500-8456", + "Shipping Address Postcode": "30258", + "Shipping Address State": "South Dakota" + }, + { + "First Name": "Stone", + "Last Name": "Jerde", + "Company Name": "Cassin, Casper and Maggio", + "Display Name": "Clint McLaughlin", + "Email": "nathanael22@yahoo.com", + "Personal Phone Number": "562-790-6059", + "Work Phone Number": "686-838-0027", + "Website": "http://google.com", + "Opening Balance": 54302.23, + "Opening Balance At": "2022-02-02", + "Opening Balance Ex. Rate": 2, + "Currency": "LYD", + "Active": "F", + "Note": "Quis cumque molestias rerum.", + "Billing Address 1": "22590 Cathy Harbor", + "Billing Address 2": "24493 Brycen Brooks", + "Billing Address City": "Elnorashire", + "Billing Address Country": "Andorra", + "Billing Address Phone": "701-852-8005", + "Billing Address Postcode": "5680", + "Billing Address State": "Nevada", + "Shipping Address 1": "5355 Erdman Bridge", + "Shipping Address 2": "421 Jeanette Camp", + "Shipping Address City": "East Philip", + "Shipping Address Country": "Venezuela", + "Shipping Address Phone": "426-119-0858", + "Shipping Address Postcode": "34929-0501", + "Shipping Address State": "Tennessee" + } +] diff --git a/packages/server/src/services/Import/ImportFileCommon.ts b/packages/server/src/services/Import/ImportFileCommon.ts index 86bfadf6c..9562e083a 100644 --- a/packages/server/src/services/Import/ImportFileCommon.ts +++ b/packages/server/src/services/Import/ImportFileCommon.ts @@ -10,13 +10,14 @@ import { ImportInsertError, ImportOperError, ImportOperSuccess, + ImportableContext, } from './interfaces'; -import { AccountsImportable } from '../Accounts/AccountsImportable'; import { ServiceError } from '@/exceptions'; -import { trimObject } from './_utils'; +import { getUniqueImportableValue, 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 +40,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 +58,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 +67,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,30 +84,54 @@ 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); + const rowNumber = index + 1; + const uniqueValue = getUniqueImportableValue(importableFields, objectDTO); + const errorContext = { + rowNumber, + uniqueValue, + }; 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) { - const error = [ + const error: ImportInsertError[] = [ { - errorCode: 'ValidationError', + errorCode: 'ServiceError', errorMessage: err.message || err.errorType, - rowNumber: index + 1, + ...errorContext, + }, + ]; + failed.push({ index, error }); + } else { + const error: ImportInsertError[] = [ + { + errorCode: 'UnknownError', + errorMessage: 'Unknown error occurred', + ...errorContext, }, ]; failed.push({ index, error }); } } } catch (errors) { - const error = errors.map((er) => ({ ...er, rowNumber: index + 1 })); + const error = errors.map((er) => ({ ...er, ...errorContext })); failed.push({ index, error }); } }; @@ -115,6 +140,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/ImportFileDataValidator.ts b/packages/server/src/services/Import/ImportFileDataValidator.ts index 0f41f2bb4..143533c66 100644 --- a/packages/server/src/services/Import/ImportFileDataValidator.ts +++ b/packages/server/src/services/Import/ImportFileDataValidator.ts @@ -32,10 +32,14 @@ export class ImportFileDataValidator { try { await YupSchema.validate(_data, { abortEarly: false }); } catch (validationError) { - const errors = validationError.inner.map((error) => ({ - errorCode: 'ValidationError', - errorMessage: error.errors, - })); + const errors = validationError.inner.reduce((errors, error) => { + const newErrors = error.errors.map((errMsg) => ({ + errorCode: 'ValidationError', + errorMessage: errMsg, + })); + return [...errors, ...newErrors]; + }, []); + throw errors; } } 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 19135d94a..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,33 +51,61 @@ 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 resourceColumns = this.resourceService.getResourceImportableFields( + const resourceColumnsMap = this.resourceService.getResourceImportableFields( tenantId, - _resourceName - ); - const resourceColumnsTransformeed = Object.entries(resourceColumns).map( - ([key, { name }]: [string, IModelMetaField]) => ({ key, name }) + resource ); + const resourceColumns = this.getResourceColumns(resourceColumnsMap); + return { import: { importId: importFile.importId, resource: importFile.resource, }, sheetColumns, - resourceColumns: resourceColumnsTransformeed, + resourceColumns, }; } + + getResourceColumns(resourceColumns: { [key: string]: IModelMetaField }) { + return Object.entries(resourceColumns) + .map( + ([key, { name, importHint, required, order }]: [ + string, + IModelMetaField + ]) => ({ + key, + name, + required, + hint: importHint, + order, + }) + ) + .sort((a, b) => + a.order && b.order ? a.order - b.order : a.order ? -1 : b.order ? 1 : 0 + ); + } } 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..6c94bfade 100644 --- a/packages/server/src/services/Import/_utils.ts +++ b/packages/server/src/services/Import/_utils.ts @@ -1,8 +1,19 @@ import * as Yup from 'yup'; -import { upperFirst, camelCase, first } from 'lodash'; +import { defaultTo, upperFirst, camelCase, first, isUndefined, pickBy } 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]) => { @@ -25,13 +36,13 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => { fieldSchema = Yup.string().label(field.name); if (field.fieldType === 'text') { - if (field.minLength) { + if (!isUndefined(field.minLength)) { fieldSchema = fieldSchema.min( field.minLength, `Minimum length is ${field.minLength} characters` ); } - if (field.maxLength) { + if (!isUndefined(field.maxLength)) { fieldSchema = fieldSchema.max( field.maxLength, `Maximum length is ${field.maxLength} characters` @@ -39,6 +50,13 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => { } } else if (field.fieldType === 'number') { fieldSchema = Yup.number().label(field.name); + + if (!isUndefined(field.max)) { + fieldSchema = fieldSchema.max(field.max); + } + if (!isUndefined(field.min)) { + fieldSchema = fieldSchema.min(field.min); + } } else if (field.fieldType === 'boolean') { fieldSchema = Yup.boolean().label(field.name); } else if (field.fieldType === 'enumeration') { @@ -47,6 +65,20 @@ 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(); + } + ); + } else if (field.fieldType === 'url') { + fieldSchema = fieldSchema.url(); } if (field.required) { fieldSchema = fieldSchema.required(); @@ -56,14 +88,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) @@ -77,3 +101,24 @@ export const sanitizeResourceName = (resourceName: string) => { export const getSheetColumns = (sheetData: unknown[]) => { return Object.keys(first(sheetData)); }; + +/** + * Retrieves the unique value from the given imported object DTO based on the + * configured unique resource field. + * @param {{ [key: string]: IModelMetaField }} importableFields - + * @param {} + * @returns {string} + */ +export const getUniqueImportableValue = ( + importableFields: { [key: string]: IModelMetaField }, + objectDTO: Record +) => { + const uniqueImportableValue = pickBy( + importableFields, + (field) => field.unique + ); + const uniqueImportableKeys = Object.keys(uniqueImportableValue); + const uniqueImportableKey = first(uniqueImportableKeys); + + return defaultTo(objectDTO[uniqueImportableKey], ''); +}; diff --git a/packages/server/src/services/Import/interfaces.ts b/packages/server/src/services/Import/interfaces.ts index 3eca339f7..b3cb9d8e1 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 { @@ -25,7 +27,12 @@ export interface ImportFileUploadPOJO { resource: string; }; sheetColumns: string[]; - resourceColumns: { key: string; name: string }[]; + resourceColumns: { + key: string; + name: string; + required?: boolean; + hint?: string; + }[]; } export interface ImportFileMapPOJO { @@ -45,13 +52,25 @@ export interface ImportFilePreviewPOJO { unmappedColumnsCount: number; } - export interface ImportOperSuccess { data: unknown; index: number; } export interface ImportOperError { - error: ImportInsertError; + error: ImportInsertError[]; index: number; -} \ No newline at end of file +} + +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/package.json b/packages/webapp/package.json index 9d7ab57ae..5d8e33da8 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -20,11 +20,11 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", + "@tiptap/core": "2.1.13", "@tiptap/extension-color": "latest", + "@tiptap/extension-list-item": "2.1.13", "@tiptap/extension-text-style": "2.1.13", - "@tiptap/core": "2.1.13", "@tiptap/pm": "2.1.13", - "@tiptap/extension-list-item": "2.1.13", "@tiptap/react": "2.1.13", "@tiptap/starter-kit": "2.1.13", "@types/jest": "^26.0.15", @@ -38,9 +38,9 @@ "@types/react-redux": "^7.1.24", "@types/react-router-dom": "^5.3.3", "@types/react-transition-group": "^4.4.5", + "@types/socket.io-client": "^3.0.0", "@types/styled-components": "^5.1.25", "@types/yup": "^0.29.13", - "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^2.10.0", "@typescript-eslint/parser": "^2.10.0", "@welldone-software/why-did-you-render": "^6.0.0-rc.1", @@ -69,10 +69,9 @@ "moment": "^2.24.0", "moment-timezone": "^0.5.33", "path-browserify": "^1.0.1", - "prop-types": "15.8.1", "plaid": "^9.3.0", "plaid-threads": "^11.4.3", - "react-plaid-link": "^3.2.1", + "prop-types": "15.8.1", "query-string": "^7.1.1", "ramda": "^0.27.1", "react": "^18.2.0", @@ -82,11 +81,13 @@ "react-dev-utils": "^11.0.4", "react-dom": "^18.2.0", "react-dropzone": "^11.0.1", + "react-dropzone-esm": "^15.0.1", "react-error-boundary": "^3.0.2", "react-error-overlay": "^6.0.9", "react-hotkeys-hook": "^3.0.3", "react-intl-universal": "^2.4.7", "react-loadable": "^5.5.0", + "react-plaid-link": "^3.2.1", "react-query": "^3.6.0", "react-query-devtools": "^2.1.1", "react-redux": "^7.2.9", @@ -112,10 +113,10 @@ "rtl-detect": "^1.0.3", "sass": "^1.68.0", "semver": "6.3.0", + "socket.io-client": "^4.7.4", "style-loader": "0.23.1", "styled-components": "^5.3.1", "stylis-rtlcss": "^2.1.1", - "socket.io-client": "^4.7.4", "typescript": "^4.8.3", "yup": "^0.28.1" }, diff --git a/packages/webapp/src/components/Dropzone/Dropzone.module.css b/packages/webapp/src/components/Dropzone/Dropzone.module.css new file mode 100644 index 000000000..63a207805 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/Dropzone.module.css @@ -0,0 +1,12 @@ + + +.root { + padding: 20px; + border: 2px dotted #c5cbd3; + border-radius: 6px; + min-height: 200px; + display: flex; + flex-direction: column; + background: #fff; + position: relative; +} \ No newline at end of file diff --git a/packages/webapp/src/components/Dropzone/Dropzone.tsx b/packages/webapp/src/components/Dropzone/Dropzone.tsx new file mode 100644 index 000000000..8682a499b --- /dev/null +++ b/packages/webapp/src/components/Dropzone/Dropzone.tsx @@ -0,0 +1,291 @@ +// @ts-nocheck +import React from 'react'; +import { Ref, useCallback } from 'react'; +import clsx from 'classnames'; +import { + Accept, + DropEvent, + FileError, + FileRejection, + FileWithPath, + useDropzone, +} from 'react-dropzone-esm'; +import { DropzoneProvider } from './DropzoneProvider'; +import { DropzoneAccept, DropzoneIdle, DropzoneReject } from './DropzoneStatus'; +import { Box } from '../Layout'; +import styles from './Dropzone.module.css'; +import { CloudLoadingIndicator } from '../Indicator'; + +export type DropzoneStylesNames = 'root' | 'inner'; +export type DropzoneVariant = 'filled' | 'light'; +export type DropzoneCssVariables = { + root: + | '--dropzone-radius' + | '--dropzone-accept-color' + | '--dropzone-accept-bg' + | '--dropzone-reject-color' + | '--dropzone-reject-bg'; +}; + +export interface DropzoneProps { + /** Key of `theme.colors` or any valid CSS color to set colors of `Dropzone.Accept`, `theme.primaryColor` by default */ + acceptColor?: MantineColor; + + /** Key of `theme.colors` or any valid CSS color to set colors of `Dropzone.Reject`, `'red'` by default */ + rejectColor?: MantineColor; + + /** Key of `theme.radius` or any valid CSS value to set `border-radius`, numbers are converted to rem, `theme.defaultRadius` by default */ + radius?: MantineRadius; + + /** Determines whether files capturing should be disabled, `false` by default */ + disabled?: boolean; + + /** Called when any files are dropped to the dropzone */ + onDropAny?: (files: FileWithPath[], fileRejections: FileRejection[]) => void; + + /** Called when valid files are dropped to the dropzone */ + onDrop: (files: FileWithPath[]) => void; + + /** Called when dropped files do not meet file restrictions */ + onReject?: (fileRejections: FileRejection[]) => void; + + /** Determines whether a loading overlay should be displayed over the dropzone, `false` by default */ + loading?: boolean; + + /** Mime types of the files that dropzone can accepts. By default, dropzone accepts all file types. */ + accept?: Accept | string[]; + + /** A ref function which when called opens the file system file picker */ + openRef?: React.ForwardedRef<() => void | undefined>; + + /** Determines whether multiple files can be dropped to the dropzone or selected from file system picker, `true` by default */ + multiple?: boolean; + + /** Maximum file size in bytes */ + maxSize?: number; + + /** Name of the form control. Submitted with the form as part of a name/value pair. */ + name?: string; + + /** Maximum number of files that can be picked at once */ + maxFiles?: number; + + /** Set to autofocus the root element */ + autoFocus?: boolean; + + /** If `false`, disables click to open the native file selection dialog */ + activateOnClick?: boolean; + + /** If `false`, disables drag 'n' drop */ + activateOnDrag?: boolean; + + /** If `false`, disables Space/Enter to open the native file selection dialog. Note that it also stops tracking the focus state. */ + activateOnKeyboard?: boolean; + + /** If `false`, stops drag event propagation to parents */ + dragEventsBubbling?: boolean; + + /** Called when the `dragenter` event occurs */ + onDragEnter?: (event: React.DragEvent) => void; + + /** Called when the `dragleave` event occurs */ + onDragLeave?: (event: React.DragEvent) => void; + + /** Called when the `dragover` event occurs */ + onDragOver?: (event: React.DragEvent) => void; + + /** Called when user closes the file selection dialog with no selection */ + onFileDialogCancel?: () => void; + + /** Called when user opens the file selection dialog */ + onFileDialogOpen?: () => void; + + /** If `false`, allow dropped items to take over the current browser window */ + preventDropOnDocument?: boolean; + + /** Set to true to use the File System Access API to open the file picker instead of using an click event, defaults to true */ + useFsAccessApi?: boolean; + + /** Use this to provide a custom file aggregator */ + getFilesFromEvent?: ( + event: DropEvent, + ) => Promise>; + + /** Custom validation function. It must return null if there's no errors. */ + validator?: (file: T) => FileError | FileError[] | null; + + /** Determines whether pointer events should be enabled on the inner element, `false` by default */ + enablePointerEvents?: boolean; + + /** Props passed down to the Loader component */ + loaderProps?: LoaderProps; + + /** Props passed down to the internal Input component */ + inputProps?: React.InputHTMLAttributes; +} + +export type DropzoneFactory = Factory<{ + props: DropzoneProps; + ref: HTMLDivElement; + stylesNames: DropzoneStylesNames; + vars: DropzoneCssVariables; + staticComponents: { + Accept: typeof DropzoneAccept; + Idle: typeof DropzoneIdle; + Reject: typeof DropzoneReject; + }; +}>; + +const defaultProps: Partial = { + loading: false, + multiple: true, + maxSize: Infinity, + autoFocus: false, + activateOnClick: true, + activateOnDrag: true, + dragEventsBubbling: true, + activateOnKeyboard: true, + useFsAccessApi: true, + variant: 'light', + rejectColor: 'red', +}; + +export const Dropzone = (_props: DropzoneProps) => { + const { + // classNames, + // className, + // style, + // styles, + // unstyled, + // vars, + radius, + disabled, + loading, + multiple, + maxSize, + accept, + children, + onDropAny, + onDrop, + onReject, + openRef, + name, + maxFiles, + autoFocus, + activateOnClick, + activateOnDrag, + dragEventsBubbling, + activateOnKeyboard, + onDragEnter, + onDragLeave, + onDragOver, + onFileDialogCancel, + onFileDialogOpen, + preventDropOnDocument, + useFsAccessApi, + getFilesFromEvent, + validator, + rejectColor, + acceptColor, + enablePointerEvents, + loaderProps, + inputProps, + // mod, + classNames, + ...others + } = { + ...defaultProps, + ..._props, + }; + + const { getRootProps, getInputProps, isDragAccept, isDragReject, open } = + useDropzone({ + onDrop: onDropAny, + onDropAccepted: onDrop, + onDropRejected: onReject, + disabled: disabled || loading, + accept: Array.isArray(accept) + ? accept.reduce((r, key) => ({ ...r, [key]: [] }), {}) + : accept, + multiple, + maxSize, + maxFiles, + autoFocus, + noClick: !activateOnClick, + noDrag: !activateOnDrag, + noDragEventsBubbling: !dragEventsBubbling, + noKeyboard: !activateOnKeyboard, + onDragEnter, + onDragLeave, + onDragOver, + onFileDialogCancel, + onFileDialogOpen, + preventDropOnDocument, + useFsAccessApi, + validator, + ...(getFilesFromEvent ? { getFilesFromEvent } : null), + }); + + const isIdle = !isDragAccept && !isDragReject; + assignRef(openRef, open); + + return ( + + + +
+ {children} +
+
+
+ ); +}; + +Dropzone.displayName = '@mantine/dropzone/Dropzone'; +Dropzone.Accept = DropzoneAccept; +Dropzone.Idle = DropzoneIdle; +Dropzone.Reject = DropzoneReject; + + + + +type PossibleRef = Ref | undefined; + +export function assignRef(ref: PossibleRef, value: T) { + if (typeof ref === 'function') { + ref(value); + } else if (typeof ref === 'object' && ref !== null && 'current' in ref) { + (ref as React.MutableRefObject).current = value; + } +} + +export function mergeRefs(...refs: PossibleRef[]) { + return (node: T | null) => { + refs.forEach((ref) => assignRef(ref, node)); + }; +} + +export function useMergedRef(...refs: PossibleRef[]) { + return useCallback(mergeRefs(...refs), refs); +} \ No newline at end of file diff --git a/packages/webapp/src/components/Dropzone/DropzoneProvider.tsx b/packages/webapp/src/components/Dropzone/DropzoneProvider.tsx new file mode 100644 index 000000000..085e51b47 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/DropzoneProvider.tsx @@ -0,0 +1,12 @@ +import { createSafeContext } from './create-safe-context'; + +export interface DropzoneContextValue { + idle: boolean; + accept: boolean; + reject: boolean; +} + +export const [DropzoneProvider, useDropzoneContext] = + createSafeContext( + 'Dropzone component was not found in tree', + ); diff --git a/packages/webapp/src/components/Dropzone/DropzoneStatus.tsx b/packages/webapp/src/components/Dropzone/DropzoneStatus.tsx new file mode 100644 index 000000000..3daa99e36 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/DropzoneStatus.tsx @@ -0,0 +1,36 @@ +import React, { cloneElement } from 'react'; +import { upperFirst } from 'lodash'; +import { DropzoneContextValue, useDropzoneContext } from './DropzoneProvider'; +import { isElement } from '@/utils/is-element'; + +export interface DropzoneStatusProps { + children: React.ReactNode; +} + +type DropzoneStatusComponent = React.FC; + +function createDropzoneStatus(status: keyof DropzoneContextValue) { + const Component: DropzoneStatusComponent = (props) => { + const { children, ...others } = props; + + const ctx = useDropzoneContext(); + const _children = isElement(children) ? children : {children}; + + if (ctx[status]) { + return cloneElement(_children as JSX.Element, others); + } + + return null; + }; + Component.displayName = `@bigcapital/core/dropzone/${upperFirst(status)}`; + + return Component; +} + +export const DropzoneAccept = createDropzoneStatus('accept'); +export const DropzoneReject = createDropzoneStatus('reject'); +export const DropzoneIdle = createDropzoneStatus('idle'); + +export type DropzoneAcceptProps = DropzoneStatusProps; +export type DropzoneRejectProps = DropzoneStatusProps; +export type DropzoneIdleProps = DropzoneStatusProps; diff --git a/packages/webapp/src/components/Dropzone/create-safe-context.tsx b/packages/webapp/src/components/Dropzone/create-safe-context.tsx new file mode 100644 index 000000000..f6e43a0df --- /dev/null +++ b/packages/webapp/src/components/Dropzone/create-safe-context.tsx @@ -0,0 +1,25 @@ +import React, { createContext, useContext } from 'react'; + +export function createSafeContext(errorMessage: string) { + const Context = createContext(null); + + const useSafeContext = () => { + const ctx = useContext(Context); + + if (ctx === null) { + throw new Error(errorMessage); + } + + return ctx; + }; + + const Provider = ({ + children, + value, + }: { + value: ContextValue; + children: React.ReactNode; + }) => {children}; + + return [Provider, useSafeContext] as const; +} diff --git a/packages/webapp/src/components/Dropzone/index.ts b/packages/webapp/src/components/Dropzone/index.ts new file mode 100644 index 000000000..1d815a4a3 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/index.ts @@ -0,0 +1 @@ +export * from './Dropzone'; \ No newline at end of file diff --git a/packages/webapp/src/components/Dropzone/mine-types.ts b/packages/webapp/src/components/Dropzone/mine-types.ts new file mode 100644 index 000000000..01f0475b2 --- /dev/null +++ b/packages/webapp/src/components/Dropzone/mine-types.ts @@ -0,0 +1,39 @@ +export const MIME_TYPES = { + // Images + png: 'image/png', + gif: 'image/gif', + jpeg: 'image/jpeg', + svg: 'image/svg+xml', + webp: 'image/webp', + avif: 'image/avif', + heic: 'image/heic', + + // Documents + mp4: 'video/mp4', + zip: 'application/zip', + csv: 'text/csv', + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + exe: 'application/vnd.microsoft.portable-executable', +} as const; + +export const IMAGE_MIME_TYPE = [ + MIME_TYPES.png, + MIME_TYPES.gif, + MIME_TYPES.jpeg, + MIME_TYPES.svg, + MIME_TYPES.webp, + MIME_TYPES.avif, + MIME_TYPES.heic, +]; + +export const PDF_MIME_TYPE = [MIME_TYPES.pdf]; +export const MS_WORD_MIME_TYPE = [MIME_TYPES.doc, MIME_TYPES.docx]; +export const MS_EXCEL_MIME_TYPE = [MIME_TYPES.xls, MIME_TYPES.xlsx]; +export const MS_POWERPOINT_MIME_TYPE = [MIME_TYPES.ppt, MIME_TYPES.pptx]; +export const EXE_MIME_TYPE = [MIME_TYPES.exe]; diff --git a/packages/webapp/src/components/Hint/FieldHint.tsx b/packages/webapp/src/components/Hint/FieldHint.tsx index eee87299f..796600a85 100644 --- a/packages/webapp/src/components/Hint/FieldHint.tsx +++ b/packages/webapp/src/components/Hint/FieldHint.tsx @@ -1,14 +1,27 @@ // @ts-nocheck import React from 'react'; -import { Tooltip } from '@blueprintjs/core'; +import { Position, Tooltip } from '@blueprintjs/core'; import { Icon } from '../Icon'; import '@/style/components/Hint.scss'; +import { Tooltip2Props } from '@blueprintjs/popover2'; + +interface HintProps { + content: string; + position?: Position; + iconSize?: number; + tooltipProps?: Partial; +} /** * Field hint. */ -export function FieldHint({ content, position, iconSize = 12, tooltipProps }) { +export function FieldHint({ + content, + position, + iconSize = 12, + tooltipProps, +}: HintProps) { return ( diff --git a/packages/webapp/src/components/Icon/index.tsx b/packages/webapp/src/components/Icon/index.tsx index 83ff5b2ae..beb5c9ba2 100644 --- a/packages/webapp/src/components/Icon/index.tsx +++ b/packages/webapp/src/components/Icon/index.tsx @@ -17,11 +17,20 @@ import classNames from 'classnames'; import * as React from 'react'; -import { Classes } from '@blueprintjs/core'; +import { Classes, Props } from '@blueprintjs/core'; import IconSvgPaths from '@/static/json/icons'; import PropTypes from 'prop-types'; +export interface IconProps extends Props { + color?: string; + htmlTitle?: string; + icon: IconName | MaybeElement; + iconSize?: number; + style?: object; + tagName?: keyof JSX.IntrinsicElements; + title?: string; +} -export class Icon extends React.Component { +export class Icon extends React.Component { static displayName = `af.Icon`; static SIZE_STANDARD = 16; diff --git a/packages/webapp/src/components/Layout/Box/Box.tsx b/packages/webapp/src/components/Layout/Box/Box.tsx index fdc7fd949..15acc305b 100644 --- a/packages/webapp/src/components/Layout/Box/Box.tsx +++ b/packages/webapp/src/components/Layout/Box/Box.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import { HTMLDivProps, Props } from '@blueprintjs/core'; -export interface BoxProps { +export interface BoxProps extends Props, HTMLDivProps { className?: string; } diff --git a/packages/webapp/src/components/Layout/Stack/Stack.tsx b/packages/webapp/src/components/Layout/Stack/Stack.tsx index da1a9af6c..d5960ac1f 100644 --- a/packages/webapp/src/components/Layout/Stack/Stack.tsx +++ b/packages/webapp/src/components/Layout/Stack/Stack.tsx @@ -30,7 +30,7 @@ export function Stack(props: StackProps) { const StackStyled = styled(Box)` display: flex; flex-direction: column; - align-items: align; + align-items: ${(props: StackProps) => props.align}; justify-content: justify; gap: ${(props: StackProps) => props.spacing}px; `; diff --git a/packages/webapp/src/components/Section/Section.tsx b/packages/webapp/src/components/Section/Section.tsx new file mode 100644 index 000000000..e46578a3d --- /dev/null +++ b/packages/webapp/src/components/Section/Section.tsx @@ -0,0 +1,248 @@ +import classNames from 'classnames'; +import React from 'react'; +import { + Card, + Collapse, + type CollapseProps, + Elevation, + Utils, + DISPLAYNAME_PREFIX, + type HTMLDivProps, + type MaybeElement, + type Props, + IconName, +} from '@blueprintjs/core'; +import { H6 } from '@blueprintjs/core'; +import { CLASSES } from '@/constants'; +import { Icon } from '../Icon'; + +/** + * Subset of {@link Elevation} options which are visually supported by the {@link Section} component. + * + * Note that an elevation greater than 1 creates too much visual clutter/noise in the UI, especially when + * multiple Sections are shown on a single page. + */ +export type SectionElevation = typeof Elevation.ZERO | typeof Elevation.ONE; + +export interface SectionCollapseProps + extends Pick< + CollapseProps, + 'className' | 'isOpen' | 'keepChildrenMounted' | 'transitionDuration' + > { + /** + * Whether the component is initially open or closed. + * + * This prop has no effect if `collapsible={false}` or the component is in controlled mode, + * i.e. when `isOpen` is **not** `undefined`. + * + * @default true + */ + defaultIsOpen?: boolean; + + /** + * Whether the component is open or closed. + * + * Passing a boolean value to `isOpen` will enabled controlled mode for the component. + */ + isOpen?: boolean; + + /** + * Callback invoked in controlled mode when the collapse toggle element is clicked. + */ + onToggle?: () => void; +} + +export interface SectionProps extends Props, Omit { + /** + * Whether this section's contents should be collapsible. + * + * @default false + */ + collapsible?: boolean; + + /** + * Subset of props to forward to the underlying {@link Collapse} component, with the addition of a + * `defaultIsOpen` option which sets the default open state of the component when in uncontrolled mode. + */ + collapseProps?: SectionCollapseProps; + + /** + * Whether this section should use compact styles. + * + * @default false + */ + compact?: boolean; + + /** + * Visual elevation of this container element. + * + * @default Elevation.ZERO + */ + elevation?: SectionElevation; + + /** + * Name of a Blueprint UI icon (or an icon element) to render in the section's header. + * Note that the header will only be rendered if `title` is provided. + */ + icon?: IconName | MaybeElement; + + /** + * Element to render on the right side of the section header. + * Note that the header will only be rendered if `title` is provided. + */ + rightElement?: JSX.Element; + + /** + * Sub-title of the section. + * Note that the header will only be rendered if `title` is provided. + */ + subtitle?: JSX.Element | string; + + /** + * Title of the section. + * Note that the header will only be rendered if `title` is provided. + */ + title?: JSX.Element | string; + + /** + * Optional title renderer function. If provided, it is recommended to include a Blueprint `
` element + * as part of the title. The render function is supplied with `className` and `id` attributes which you must + * forward to the DOM. The `title` prop is also passed along to this renderer via `props.children`. + * + * @default H6 + */ + titleRenderer?: React.FC>; +} + +/** + * Section component. + * + * @see https://blueprintjs.com/docs/#core/components/section + */ +export const Section: React.FC = React.forwardRef( + (props, ref) => { + const { + children, + className, + collapseProps, + collapsible, + compact, + elevation, + icon, + rightElement, + subtitle, + title, + titleRenderer = H6, + ...htmlProps + } = props; + // Determine whether to use controlled or uncontrolled state. + const isControlled = collapseProps?.isOpen != null; + + // The initial useState value is negated in order to conform to the `isCollapsed` expectation. + const [isCollapsedUncontrolled, setIsCollapsed] = React.useState( + !(collapseProps?.defaultIsOpen ?? true), + ); + + const isCollapsed = isControlled + ? !collapseProps?.isOpen + : isCollapsedUncontrolled; + + const toggleIsCollapsed = React.useCallback(() => { + if (isControlled) { + collapseProps?.onToggle?.(); + } else { + setIsCollapsed(!isCollapsed); + } + }, [collapseProps, isCollapsed, isControlled]); + + const isHeaderRightContainerVisible = rightElement != null || collapsible; + + const sectionId = Utils.uniqueId('section'); + const sectionTitleId = title ? Utils.uniqueId('section-title') : undefined; + + return ( + + {title && ( +
+
+ {/* {icon && ( + + )} */} +
+ {React.createElement( + titleRenderer, + { + className: CLASSES.SECTION_HEADER_TITLE, + id: sectionTitleId, + }, + title, + )} + {subtitle && ( +
+ {subtitle} +
+ )} +
+
+ {isHeaderRightContainerVisible && ( +
+ {rightElement} + {collapsible && + (isCollapsed ? ( + + ) : ( + + ))} +
+ )} +
+ )} + {collapsible ? ( + // @ts-ignore + + {children} + + ) : ( + children + )} +
+ ); + }, +); +Section.defaultProps = { + compact: false, + elevation: Elevation.ZERO, +}; +Section.displayName = `${DISPLAYNAME_PREFIX}.Section`; diff --git a/packages/webapp/src/components/Section/SectionCard.tsx b/packages/webapp/src/components/Section/SectionCard.tsx new file mode 100644 index 000000000..9ff52e480 --- /dev/null +++ b/packages/webapp/src/components/Section/SectionCard.tsx @@ -0,0 +1,41 @@ +import classNames from 'classnames'; +import * as React from 'react'; +import { DISPLAYNAME_PREFIX, HTMLDivProps, Props } from '@blueprintjs/core'; +import { CLASSES } from '@/constants'; + +export interface SectionCardProps + extends Props, + HTMLDivProps, + React.RefAttributes { + /** + * Whether to apply visual padding inside the content container element. + * + * @default true + */ + padded?: boolean; +} + +/** + * Section card component. + * + * @see https://blueprintjs.com/docs/#core/components/section.section-card + */ +export const SectionCard: React.FC = React.forwardRef( + (props, ref) => { + const { className, children, padded, ...htmlProps } = props; + const classes = classNames( + CLASSES.SECTION_CARD, + { [CLASSES.PADDED]: padded }, + className, + ); + return ( +
+ {children} +
+ ); + }, +); +SectionCard.defaultProps = { + padded: true, +}; +SectionCard.displayName = `${DISPLAYNAME_PREFIX}.SectionCard`; diff --git a/packages/webapp/src/components/Section/index.ts b/packages/webapp/src/components/Section/index.ts new file mode 100644 index 000000000..ca7c2dece --- /dev/null +++ b/packages/webapp/src/components/Section/index.ts @@ -0,0 +1,2 @@ +export * from './Section'; +export * from './SectionCard'; \ No newline at end of file diff --git a/packages/webapp/src/components/Stepper/Stepper.tsx b/packages/webapp/src/components/Stepper/Stepper.tsx new file mode 100644 index 000000000..448398628 --- /dev/null +++ b/packages/webapp/src/components/Stepper/Stepper.tsx @@ -0,0 +1,111 @@ +// @ts-nocheck +import { cloneElement } from 'react'; +import styled from 'styled-components'; +import { toArray } from 'lodash'; +import { Box } from '../Layout'; +import { StepperCompleted } from './StepperCompleted'; +import { StepperStep } from './StepperStep'; +import { StepperStepState } from './types'; + +export interface StepperProps { + /** components */ + children: React.ReactNode; + + /** Index of the active step */ + active: number; + + /** Called when step is clicked */ + onStepClick?: (stepIndex: number) => void; + + /** Determines whether next steps can be selected, `true` by default **/ + allowNextStepsSelect?: boolean; + + classNames?: Record; +} + +export function Stepper({ + active, + onStepClick, + children, + classNames, +}: StepperProps) { + const convertedChildren = toArray(children) as React.ReactElement[]; + const _children = convertedChildren.filter( + (child) => child.type !== StepperCompleted, + ); + const completedStep = convertedChildren.find( + (item) => item.type === StepperCompleted, + ); + const items = _children.reduce((acc, item, index) => { + const state = + active === index + ? StepperStepState.Progress + : active > index + ? StepperStepState.Completed + : StepperStepState.Inactive; + + const shouldAllowSelect = () => { + if (typeof onStepClick !== 'function') { + return false; + } + if (typeof item.props.allowStepSelect === 'boolean') { + return item.props.allowStepSelect; + } + return state === 'stepCompleted' || allowNextStepsSelect; + }; + const isStepSelectionEnabled = shouldAllowSelect(); + + acc.push( + cloneElement(item, { + key: index, + step: index + 1, + state, + onClick: () => isStepSelectionEnabled && onStepClick?.(index), + allowStepClick: isStepSelectionEnabled, + }), + ); + if (index !== _children.length - 1) { + acc.push( + , + ); + } + return acc; + }, []); + + const stepContent = _children[active]?.props?.children; + const completedContent = completedStep?.props?.children; + const content = + active > _children.length - 1 ? completedContent : stepContent; + + return ( + + {items} + {content} + + ); +} + +Stepper.Step = StepperStep; +Stepper.Completed = StepperCompleted; +Stepper.displayName = '@bigcapital/core/stepper'; + +const StepsItems = styled(Box)` + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +`; +const StepsContent = styled(Box)` + margin-top: 16px; + margin-bottom: 8px; +`; +const StepSeparator = styled.div` + flex: 1; + display: block; + border-color: #c5cbd3; + border-top-style: solid; + border-top-width: 1px; +`; diff --git a/packages/webapp/src/components/Stepper/StepperCompleted.tsx b/packages/webapp/src/components/Stepper/StepperCompleted.tsx new file mode 100644 index 000000000..e382a3668 --- /dev/null +++ b/packages/webapp/src/components/Stepper/StepperCompleted.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export interface StepperCompletedProps { + /** Label content */ + children: React.ReactNode; +} + +export const StepperCompleted: React.FC = () => null; +StepperCompleted.displayName = '@bigcapital/core/StepperCompleted'; diff --git a/packages/webapp/src/components/Stepper/StepperStep.tsx b/packages/webapp/src/components/Stepper/StepperStep.tsx new file mode 100644 index 000000000..2103e5b81 --- /dev/null +++ b/packages/webapp/src/components/Stepper/StepperStep.tsx @@ -0,0 +1,102 @@ +// @ts-nocheck +import { StepperStepState } from './types'; +import styled from 'styled-components'; +import { Icon } from '../Icon'; + +interface StepperStepProps { + label: string; + description?: string; + children: React.ReactNode; + step?: number; + active?: boolean; + state?: StepperStepState; + allowStepClick?: boolean; +} + +export function StepperStep({ + label, + description, + step, + active, + state, + children, +}: StepperStepProps) { + return ( + + + + {state === StepperStepState.Completed && ( + + )} + {step} + + + + + + {label} + + {description && ( + + {description} + + )} + + + ); +} + +const StepButton = styled.button` + background: transparent; + color: inherit; + border: 0; + align-items: center; + display: flex; + gap: 10px; + text-align: left; +`; + +const StepIcon = styled.span` + display: block; + height: 24px; + width: 24px; + display: block; + line-height: 24px; + border-radius: 24px; + text-align: center; + background-color: ${(props) => + props.isCompleted || props.isActive ? 'rgb(0, 82, 204)' : '#9e9e9e'}; + color: #fff; + margin: auto; + font-size: 12px; +`; + +const StepTitle = styled.div` + color: ${(props) => + props.isCompleted || props.isActive ? 'rgb(0, 82, 204)' : '#738091'}; +`; +const StepDescription = styled.div` + font-size: 12px; + margin-top: 10px; + color: ${(props) => + props.isCompleted || props.isActive ? 'rgb(0, 82, 204)' : '#738091'}; +`; + +const StepIconWrap = styled.div` + display: flex; +`; + +const StepTextWrap = styled.div` + text-align: left; +`; + +const StepIconText = styled.div``; diff --git a/packages/webapp/src/components/Stepper/index.ts b/packages/webapp/src/components/Stepper/index.ts new file mode 100644 index 000000000..4b2d6faf6 --- /dev/null +++ b/packages/webapp/src/components/Stepper/index.ts @@ -0,0 +1 @@ +export * from './Stepper'; \ No newline at end of file diff --git a/packages/webapp/src/components/Stepper/types.ts b/packages/webapp/src/components/Stepper/types.ts new file mode 100644 index 000000000..35334873b --- /dev/null +++ b/packages/webapp/src/components/Stepper/types.ts @@ -0,0 +1,7 @@ + + +export enum StepperStepState { + Progress = 'stepProgress', + Completed = 'stepCompleted', + Inactive = 'stepInactive', +} \ No newline at end of file diff --git a/packages/webapp/src/constants/classes.tsx b/packages/webapp/src/constants/classes.tsx index 778829263..c708cd453 100644 --- a/packages/webapp/src/constants/classes.tsx +++ b/packages/webapp/src/constants/classes.tsx @@ -1,6 +1,21 @@ // @ts-nocheck import { Classes } from '@blueprintjs/core'; +export const NS = 'bp4'; + +export const SECTION = `${NS}-section`; +export const SECTION_COLLAPSED = `${SECTION}-collapsed`; +export const SECTION_HEADER = `${SECTION}-header`; +export const SECTION_HEADER_LEFT = `${SECTION_HEADER}-left`; +export const SECTION_HEADER_TITLE = `${SECTION_HEADER}-title`; +export const SECTION_HEADER_SUB_TITLE = `${SECTION_HEADER}-sub-title`; +export const SECTION_HEADER_DIVIDER = `${SECTION_HEADER}-divider`; +export const SECTION_HEADER_TABS = `${SECTION_HEADER}-tabs`; +export const SECTION_HEADER_RIGHT = `${SECTION_HEADER}-right`; +export const SECTION_CARD = `${SECTION}-card`; + +export const PADDED = `${NS}-padded`; + const CLASSES = { DASHBOARD_PAGE: 'dashboard__page', DASHBOARD_DATATABLE: 'dashboard__datatable', @@ -16,7 +31,7 @@ const CLASSES = { DASHBOARD_CONTENT_PREFERENCES: 'dashboard-content--preferences', DASHBOARD_CONTENT_PANE: 'Pane2', DASHBOARD_CENTERED_EMPTY_STATUS: 'dashboard__centered-empty-status', - + PAGE_FORM: 'page-form', PAGE_FORM_HEADER: 'page-form__header', PAGE_FORM_HEADER_PRIMARY: 'page-form__primary-section', @@ -40,9 +55,9 @@ const CLASSES = { PAGE_FORM_ITEM: 'page-form--item', PAGE_FORM_MAKE_JOURNAL: 'page-form--make-journal-entries', PAGE_FORM_EXPENSE: 'page-form--expense', - PAGE_FORM_CREDIT_NOTE:'page-form--credit-note', - PAGE_FORM_VENDOR_CREDIT_NOTE:'page-form--vendor-credit-note', - PAGE_FORM_WAREHOUSE_TRANSFER:'page-form--warehouse-transfer', + PAGE_FORM_CREDIT_NOTE: 'page-form--credit-note', + PAGE_FORM_VENDOR_CREDIT_NOTE: 'page-form--vendor-credit-note', + PAGE_FORM_WAREHOUSE_TRANSFER: 'page-form--warehouse-transfer', FORM_GROUP_LIST_SELECT: 'form-group--select-list', @@ -66,31 +81,42 @@ const CLASSES = { PREFERENCES_TOPBAR: 'preferences-topbar', PREFERENCES_PAGE_INSIDE_CONTENT: 'preferences-page__inside-content', - PREFERENCES_PAGE_INSIDE_CONTENT_GENERAL: 'preferences-page__inside-content--general', - PREFERENCES_PAGE_INSIDE_CONTENT_USERS: 'preferences-page__inside-content--users', - PREFERENCES_PAGE_INSIDE_CONTENT_CURRENCIES: 'preferences-page__inside-content--currencies', - PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT: 'preferences-page__inside-content--accountant', - PREFERENCES_PAGE_INSIDE_CONTENT_SMS_INTEGRATION: 'preferences-page__inside-content--sms-integration', - PREFERENCES_PAGE_INSIDE_CONTENT_ROLES_FORM: 'preferences-page__inside-content--roles-form', - PREFERENCES_PAGE_INSIDE_CONTENT_BRANCHES: 'preferences-page__inside-content--branches', - PREFERENCES_PAGE_INSIDE_CONTENT_WAREHOUSES: 'preferences-page__inside-content--warehouses', + PREFERENCES_PAGE_INSIDE_CONTENT_GENERAL: + 'preferences-page__inside-content--general', + PREFERENCES_PAGE_INSIDE_CONTENT_USERS: + 'preferences-page__inside-content--users', + PREFERENCES_PAGE_INSIDE_CONTENT_CURRENCIES: + 'preferences-page__inside-content--currencies', + PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT: + 'preferences-page__inside-content--accountant', + PREFERENCES_PAGE_INSIDE_CONTENT_SMS_INTEGRATION: + 'preferences-page__inside-content--sms-integration', + PREFERENCES_PAGE_INSIDE_CONTENT_ROLES_FORM: + 'preferences-page__inside-content--roles-form', + PREFERENCES_PAGE_INSIDE_CONTENT_BRANCHES: + 'preferences-page__inside-content--branches', + PREFERENCES_PAGE_INSIDE_CONTENT_WAREHOUSES: + 'preferences-page__inside-content--warehouses', FINANCIAL_REPORT_INSIDER: 'dashboard__insider--financial-report', - UNIVERSAL_SEARCH: 'universal-search', UNIVERSAL_SEARCH_OMNIBAR: 'universal-search__omnibar', UNIVERSAL_SEARCH_OVERLAY: 'universal-search-overlay', UNIVERSAL_SEARCH_INPUT: 'universal-search__input', - UNIVERSAL_SEARCH_INPUT_RIGHT_ELEMENTS: 'universal-search-input-right-elements', + UNIVERSAL_SEARCH_INPUT_RIGHT_ELEMENTS: + 'universal-search-input-right-elements', UNIVERSAL_SEARCH_TYPE_SELECT_OVERLAY: 'universal-search__type-select-overlay', UNIVERSAL_SEARCH_TYPE_SELECT_BTN: 'universal-search__type-select-btn', UNIVERSAL_SEARCH_FOOTER: 'universal-search__footer', UNIVERSAL_SEARCH_ACTIONS: 'universal-search__actions', - UNIVERSAL_SEARCH_ACTION_SELECT: 'universal-search__action universal-search__action--select', - UNIVERSAL_SEARCH_ACTION_CLOSE: 'universal-search__action universal-search__action--close', - UNIVERSAL_SEARCH_ACTION_ARROWS: 'universal-search__action universal-search__action--arrows', + UNIVERSAL_SEARCH_ACTION_SELECT: + 'universal-search__action universal-search__action--select', + UNIVERSAL_SEARCH_ACTION_CLOSE: + 'universal-search__action universal-search__action--close', + UNIVERSAL_SEARCH_ACTION_ARROWS: + 'universal-search__action universal-search__action--arrows', DIALOG_PDF_PREVIEW: 'dialog--pdf-preview-dialog', @@ -98,8 +124,19 @@ const CLASSES = { CARD: 'card', ALIGN_RIGHT: 'align-right', FONT_BOLD: 'font-bold', + + NS, + PADDED, + SECTION, + SECTION_COLLAPSED, + SECTION_HEADER, + SECTION_HEADER_LEFT, + SECTION_HEADER_TITLE, + SECTION_HEADER_SUB_TITLE, + SECTION_HEADER_DIVIDER, + SECTION_HEADER_TABS, + SECTION_HEADER_RIGHT, + SECTION_CARD, }; -export { - CLASSES, -} +export { CLASSES }; diff --git a/packages/webapp/src/containers/Accounts/AccountsActionsBar.tsx b/packages/webapp/src/containers/Accounts/AccountsActionsBar.tsx index 38d7a8214..dbffc59ec 100644 --- a/packages/webapp/src/containers/Accounts/AccountsActionsBar.tsx +++ b/packages/webapp/src/containers/Accounts/AccountsActionsBar.tsx @@ -20,7 +20,7 @@ import { DashboardActionViewsList, DashboardFilterButton, DashboardRowsHeightButton, - DashboardActionsBar + DashboardActionsBar, } from '@/components'; import { AccountAction, AbilitySubject } from '@/constants/abilityOption'; @@ -37,6 +37,7 @@ import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import { compose } from '@/utils'; +import { useHistory } from 'react-router-dom'; /** * Accounts actions bar. @@ -67,6 +68,8 @@ function AccountsActionsBar({ }) { const { resourceViews, fields } = useAccountsChartContext(); + const history = useHistory(); + const onClickNewAccount = () => { openDialog(DialogsName.AccountForm, {}); }; @@ -111,6 +114,11 @@ function AccountsActionsBar({ const handleTableRowSizeChange = (size) => { addSetting('accounts', 'tableSize', size); }; + // handle the import button click. + const handleImportBtnClick = () => { + history.push('/accounts/import'); + }; + return ( @@ -183,6 +191,7 @@ function AccountsActionsBar({ className={Classes.MINIMAL} icon={} text={} + onClick={handleImportBtnClick} /> { + 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({ + + ) : ( + +

+ Drag images here or click to select files +

+ + Drag and Drop file here or Choose file + +
+ )} + + + + + ); +} diff --git a/packages/webapp/src/containers/Import/ImportFileActions.module.scss b/packages/webapp/src/containers/Import/ImportFileActions.module.scss new file mode 100644 index 000000000..fa4c9d19a --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileActions.module.scss @@ -0,0 +1,5 @@ + + +.root{ + +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFileContainer.tsx b/packages/webapp/src/containers/Import/ImportFileContainer.tsx new file mode 100644 index 000000000..a2b32f84b --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileContainer.tsx @@ -0,0 +1,9 @@ +import styles from './ImportFileUploadStep.module.scss'; + +interface ImportFileContainerProps { + children: React.ReactNode; +} + +export function ImportFileContainer({ children }: ImportFileContainerProps) { + return
{children}
; +} diff --git a/packages/webapp/src/containers/Import/ImportFileFooterActions.tsx b/packages/webapp/src/containers/Import/ImportFileFooterActions.tsx new file mode 100644 index 000000000..15b96acbe --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileFooterActions.tsx @@ -0,0 +1,28 @@ +// @ts-nocheck +import clsx from 'classnames'; +import { Group } from '@/components'; +import { CLASSES } from '@/constants'; +import { Button, Intent } from '@blueprintjs/core'; +import { useFormikContext } from 'formik'; +import styles from './ImportFileActions.module.scss'; +import { useImportFileContext } from './ImportFileProvider'; + +export function ImportFileUploadFooterActions() { + const { isSubmitting } = useFormikContext(); + const { onCancelClick } = useImportFileContext(); + + const handleCancelBtnClick = () => { + onCancelClick && onCancelClick(); + }; + + return ( +
+ + + + +
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportFileMapping.module.scss b/packages/webapp/src/containers/Import/ImportFileMapping.module.scss new file mode 100644 index 000000000..4885aeb34 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileMapping.module.scss @@ -0,0 +1,36 @@ +.table { + width: 100%; + margin-top: 1.4rem; + + th.label, + td.label{ + width: 32% !important; + } + + thead{ + th{ + border-top: 1px solid #d9d9da; + padding-top: 8px; + padding-bottom: 8px; + color: #738091; + font-weight: 500; + } + } + + tbody{ + tr td { + vertical-align: middle; + } + + tr td{ + :global(.bp4-popover-target .bp4-button), + :global(.bp4-popover-wrapper){ + max-width: 250px; + } + } + } +} + +.requiredSign{ + color: rgb(250, 82, 82); +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFileMapping.tsx b/packages/webapp/src/containers/Import/ImportFileMapping.tsx new file mode 100644 index 000000000..16ae9a19d --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileMapping.tsx @@ -0,0 +1,97 @@ +import { useMemo } from 'react'; +import clsx from 'classnames'; +import { Button, Intent, Position } from '@blueprintjs/core'; +import { useFormikContext } from 'formik'; +import { FSelect, Group, Hint } from '@/components'; +import { ImportFileMappingForm } from './ImportFileMappingForm'; +import { EntityColumn, useImportFileContext } from './ImportFileProvider'; +import { CLASSES } from '@/constants'; +import { ImportFileContainer } from './ImportFileContainer'; +import { ImportStepperStep } from './_types'; +import { ImportFileMapBootProvider } from './ImportFileMappingBoot'; +import styles from './ImportFileMapping.module.scss'; + +export function ImportFileMapping() { + const { importId } = useImportFileContext(); + + return ( + + + +

+ Review and map the column headers in your csv/xlsx file with the + Bigcapital fields. +

+ + + + + + + + + + + +
Bigcapital FieldsSheet Column Headers
+
+ + +
+
+ ); +} + +function ImportFileMappingFields() { + const { entityColumns, sheetColumns } = useImportFileContext(); + + const items = useMemo( + () => sheetColumns.map((column) => ({ value: column, text: column })), + [sheetColumns], + ); + const columnMapper = (column: EntityColumn, index: number) => ( + + + {column.name}{' '} + {column.required && *} + + + + + {column.hint && ( + + )} + + + + ); + const columns = entityColumns.map(columnMapper); + + return <>{columns}; +} + +function ImportFileMappingFloatingActions() { + const { isSubmitting } = useFormikContext(); + const { setStep } = useImportFileContext(); + + const handleCancelBtnClick = () => { + setStep(ImportStepperStep.Upload); + }; + + return ( +
+ + + + +
+ ); +} 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 new file mode 100644 index 000000000..283e06e31 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileMappingForm.tsx @@ -0,0 +1,101 @@ +// @ts-nocheck +import { Intent } from '@blueprintjs/core'; +import { useImportFileMapping } from '@/hooks/query/import'; +import { Form, Formik, FormikHelpers } from 'formik'; +import { useImportFileContext } from './ImportFileProvider'; +import { useMemo } from 'react'; +import { isEmpty, lowerCase } from 'lodash'; +import { AppToaster } from '@/components'; +import { useImportFileMapBootContext } from './ImportFileMappingBoot'; +import { transformToForm } from '@/utils'; + +interface ImportFileMappingFormProps { + children: React.ReactNode; +} + +type ImportFileMappingFormValues = Record; + +export function ImportFileMappingForm({ + children, +}: ImportFileMappingFormProps) { + const { mutateAsync: submitImportFileMapping } = useImportFileMapping(); + const { importId, setStep } = useImportFileContext(); + + const initialValues = useImportFileMappingInitialValues(); + + const handleSubmit = ( + values: ImportFileMappingFormValues, + { setSubmitting }: FormikHelpers, + ) => { + setSubmitting(true); + const _values = transformValueToReq(values); + + submitImportFileMapping([importId, _values]) + .then(() => { + setSubmitting(false); + setStep(2); + }) + .catch(({ response: { data } }) => { + if (data.errors.find((e) => e.type === 'DUPLICATED_FROM_MAP_ATTR')) { + AppToaster.show({ + message: 'Selected the same sheet columns to multiple fields.', + intent: Intent.DANGER, + }); + } + setSubmitting(false); + }); + }; + + return ( + +
{children}
+
+ ); +} + +const transformValueToReq = (value: ImportFileMappingFormValues) => { + const mapping = Object.keys(value) + .filter((key) => !isEmpty(value[key])) + .map((key) => ({ from: value[key], to: key })); + return { mapping }; +}; + +const transformResToFormValues = (value: { from: string; to: string }[]) => { + return value?.reduce((acc, map) => { + acc[map.to] = map.from; + return acc; + }, {}); +}; + +const useImportFileMappingInitialValues = () => { + const { importFile } = useImportFileMapBootContext(); + const { entityColumns, sheetColumns } = useImportFileContext(); + + const initialResValues = useMemo( + () => transformResToFormValues(importFile?.map || []), + [importFile?.map], + ); + + const initialValues = useMemo( + () => + entityColumns.reduce((acc, { key, name }) => { + const _name = lowerCase(name); + const _matched = sheetColumns.find( + (column) => lowerCase(column) === _name, + ); + // Match the default column name the same field name + // if matched one of sheet columns has the same field name. + acc[key] = _matched ? _matched : ''; + return acc; + }, {}), + [entityColumns, sheetColumns], + ); + + return useMemo( + () => ({ + ...transformToForm(initialResValues, initialValues), + ...initialValues, + }), + [initialValues, initialResValues], + ); +}; diff --git a/packages/webapp/src/containers/Import/ImportFilePreview.module.scss b/packages/webapp/src/containers/Import/ImportFilePreview.module.scss new file mode 100644 index 000000000..c62e7e738 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFilePreview.module.scss @@ -0,0 +1,36 @@ + +.previewList { + list-style: none; + margin-top: 14px; + + :global(li) { + border-top: 1px solid #d9d9da; + padding: 6px 0; + + &:last-child{ + padding-bottom: 0; + } + } +} + +.unmappedList { + padding-left: 2rem; +} + +table.skippedTable { + width: 100%; + + tbody{ + tr:first-child td { + box-shadow: 0 0 0 0; + } + tr td { + vertical-align: middle; + padding: 7px; + } + + tr:hover td{ + background: #F6F7F9; + } + } +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFilePreview.tsx b/packages/webapp/src/containers/Import/ImportFilePreview.tsx new file mode 100644 index 000000000..45b3a70ca --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFilePreview.tsx @@ -0,0 +1,178 @@ +// @ts-nocheck +import { Button, Callout, Intent, Text } from '@blueprintjs/core'; +import clsx from 'classnames'; +import { + ImportFilePreviewBootProvider, + useImportFilePreviewBootContext, +} from './ImportFilePreviewBoot'; +import { useImportFileContext } from './ImportFileProvider'; +import { useImportFileProcess } from '@/hooks/query/import'; +import { AppToaster, Box, Group, Stack } from '@/components'; +import { CLASSES } from '@/constants'; +import { ImportStepperStep } from './_types'; +import { ImportFileContainer } from './ImportFileContainer'; +import { SectionCard, Section } from '@/components/Section'; +import styles from './ImportFilePreview.module.scss'; + +export function ImportFilePreview() { + const { importId } = useImportFileContext(); + + return ( + + + + ); +} + +function ImportFilePreviewContent() { + const { importPreview } = useImportFilePreviewBootContext(); + + return ( + + + + + {importPreview.createdCount} of {importPreview.totalCount} Items in + your file are ready to be imported. + + + + + + + + + + ); +} + +function ImportFilePreviewImported() { + const { importPreview } = useImportFilePreviewBootContext(); + + return ( +
+ + + Items that are ready to be imported - {importPreview.createdCount} + +
    +
  • + Items to be created: ({importPreview.createdCount}) +
  • +
  • + Items to be skipped: ({importPreview.skippedCount}) +
  • +
  • + Items have errors: ({importPreview.errorsCount}) +
  • +
+
+
+ ); +} + +function ImportFilePreviewSkipped() { + const { importPreview } = useImportFilePreviewBootContext(); + + // Can't continue if there's no skipped items. + if (importPreview.skippedCount <= 0) return null; + + return ( +
+ + + + {importPreview?.errors.map((error, key) => ( + + + + + + ))} + +
{error.rowNumber}{error.uniqueValue}{error.errorMessage}
+
+
+ ); +} + +function ImportFilePreviewUnmapped() { + const { importPreview } = useImportFilePreviewBootContext(); + + // Can't continue if there's no unmapped columns. + if (importPreview?.unmappedColumnsCount <= 0) return null; + + return ( +
+ +
    + {importPreview.unmappedColumns?.map((column, key) => ( +
  • {column}
  • + ))} +
+
+
+ ); +} + +function ImportFilePreviewFloatingActions() { + const { importId, setStep, onImportSuccess, onImportFailed } = + useImportFileContext(); + const { importPreview } = useImportFilePreviewBootContext(); + const { mutateAsync: importFile, isLoading: isImportFileLoading } = + useImportFileProcess(); + + const isValidToImport = importPreview?.createdCount > 0; + + const handleSubmitBtn = () => { + importFile(importId) + .then(() => { + AppToaster.show({ + intent: Intent.SUCCESS, + message: `The ${ + importPreview.createdCount + } of ${10} has imported successfully.`, + }); + onImportSuccess && onImportSuccess(); + }) + .catch((error) => { + onImportFailed && onImportFailed(); + }); + }; + const handleCancelBtnClick = () => { + setStep(ImportStepperStep.Mapping); + }; + + return ( +
+ + + + +
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportFilePreviewBoot.tsx b/packages/webapp/src/containers/Import/ImportFilePreviewBoot.tsx new file mode 100644 index 000000000..3a3fcf585 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFilePreviewBoot.tsx @@ -0,0 +1,59 @@ +import { Spinner } from '@blueprintjs/core'; +import React, { createContext, useContext } from 'react'; +import { Box } from '@/components'; +import { useImportFilePreview } from '@/hooks/query/import'; + +interface ImportFilePreviewBootContextValue {} + +const ImportFilePreviewBootContext = + createContext( + {} as ImportFilePreviewBootContextValue, + ); + +export const useImportFilePreviewBootContext = () => { + const context = useContext( + ImportFilePreviewBootContext, + ); + + if (!context) { + throw new Error( + 'useImportFilePreviewBootContext must be used within an ImportFilePreviewBootProvider', + ); + } + return context; +}; + +interface ImportFilePreviewBootProps { + importId: string; + children: React.ReactNode; +} + +export const ImportFilePreviewBootProvider = ({ + importId, + children, +}: ImportFilePreviewBootProps) => { + const { + data: importPreview, + isLoading: isImportPreviewLoading, + isFetching: isImportPreviewFetching, + } = useImportFilePreview(importId, { + enabled: Boolean(importId), + }); + + const value = { + importPreview, + isImportPreviewLoading, + isImportPreviewFetching, + }; + return ( + + {isImportPreviewLoading ? ( + + + + ) : ( + <>{children} + )} + + ); +}; diff --git a/packages/webapp/src/containers/Import/ImportFileProvider.tsx b/packages/webapp/src/containers/Import/ImportFileProvider.tsx new file mode 100644 index 000000000..3e7d0233f --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileProvider.tsx @@ -0,0 +1,138 @@ +// @ts-nocheck +import React, { + Dispatch, + SetStateAction, + createContext, + useContext, + useState, +} from 'react'; + +export type EntityColumn = { + key: string; + name: string; + required?: boolean; + hint?: string; +}; +export type SheetColumn = string; +export type SheetMap = { from: string; to: string }; + +interface ImportFileContextValue { + sheetColumns: SheetColumn[]; + setSheetColumns: Dispatch>; + + entityColumns: EntityColumn[]; + setEntityColumns: Dispatch>; + + sheetMapping: SheetMap[]; + setSheetMapping: Dispatch>; + + step: number; + setStep: Dispatch>; + + importId: string; + 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, +); + +export const useImportFileContext = () => { + const context = useContext(ImportFileContext); + + if (!context) { + throw new Error( + 'useImportFileContext must be used within an ImportFileProvider', + ); + } + return context; +}; + +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([]); + const [sheetMapping, setSheetMapping] = useState([]); + const [importId, setImportId] = useState(''); + + const [step, setStep] = useState(0); + + const value = { + sheetColumns, + setSheetColumns, + + entityColumns, + setEntityColumns, + + sheetMapping, + setSheetMapping, + + step, + setStep, + + importId, + setImportId, + + resource, + description, + params, + + onImportSuccess, + onImportFailed, + onCancelClick, + + sampleFileName, + + exampleDownload, + exampleTitle, + exampleDescription, + }; + + return ( + + {children} + + ); +}; diff --git a/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx b/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx new file mode 100644 index 000000000..bc0a7e1b5 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileUploadForm.tsx @@ -0,0 +1,86 @@ +// @ts-nocheck +import { AppToaster } from '@/components'; +import { useImportFileUpload } from '@/hooks/query/import'; +import { Intent } from '@blueprintjs/core'; +import { Formik, Form, FormikHelpers } from 'formik'; +import * as Yup from 'yup'; +import { useImportFileContext } from './ImportFileProvider'; +import { ImportStepperStep } from './_types'; + +const initialValues = { + file: null, +} as ImportFileUploadValues; + +interface ImportFileUploadFormProps { + children: React.ReactNode; +} + +const validationSchema = Yup.object().shape({ + file: Yup.mixed().required('File is required'), +}); + +interface ImportFileUploadValues { + file: File | null; +} + +export function ImportFileUploadForm({ + children, + formikProps, + formProps, +}: ImportFileUploadFormProps) { + const { mutateAsync: uploadImportFile } = useImportFileUpload(); + const { + resource, + params, + setStep, + setSheetColumns, + setEntityColumns, + setImportId, + } = useImportFileContext(); + + const handleSubmit = ( + values: ImportFileUploadValues, + { setSubmitting }: FormikHelpers, + ) => { + if (!values.file) return; + + setSubmitting(true); + const formData = new FormData(); + formData.append('file', values.file); + formData.append('resource', resource); + formData.append('params', JSON.stringify(params)); + + uploadImportFile(formData) + .then(({ data }) => { + setImportId(data.import.import_id); + setSheetColumns(data.sheet_columns); + setEntityColumns(data.resource_columns); + setStep(ImportStepperStep.Mapping); + setSubmitting(false); + }) + .catch(({ response: { data } }) => { + if ( + data.errors.find( + (er) => er.type === 'IMPORTED_FILE_EXTENSION_INVALID', + ) + ) { + AppToaster.show({ + intent: Intent.DANGER, + message: 'The extenstion of uploaded file is not supported.', + }); + } + setSubmitting(false); + }); + }; + + return ( + +
{children}
+
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss b/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss new file mode 100644 index 000000000..95bfd50fd --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileUploadStep.module.scss @@ -0,0 +1,17 @@ +.root { + margin-top: 2.2rem +} + +.content { + flex: 1; + padding: 32px 20px; + padding-bottom: 80px; + min-width: 660px; + max-width: 760px; + width: 75%; + margin-left: auto; + margin-right: auto; +} + +.form { +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx b/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx new file mode 100644 index 000000000..b1c6f9e01 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportFileUploadStep.tsx @@ -0,0 +1,35 @@ +// @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 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/ImportSampleDownload.module.scss b/packages/webapp/src/containers/Import/ImportSampleDownload.module.scss new file mode 100644 index 000000000..ee230449c --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportSampleDownload.module.scss @@ -0,0 +1,23 @@ + + +.root{ + background: #fff; + border: 1px solid #D3D8DE; + border-radius: 5px; + padding: 16px; +} +.description{ + margin: 0; + margin-top: 6px; + color: #8F99A8; +} +.title{ + color: #5F6B7C; + font-weight: 600; + font-size: 14px; +} + +.buttonWrap{ + flex: 25% 0; + text-align: right; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportSampleDownload.tsx b/packages/webapp/src/containers/Import/ImportSampleDownload.tsx new file mode 100644 index 000000000..37a669b44 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportSampleDownload.tsx @@ -0,0 +1,65 @@ +// @ts-nocheck +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 ( + + +

{exampleTitle}

+

{exampleDescription}

+
+ + + + + + + } + interactionKind={PopoverInteractionKind.CLICK} + placement="bottom-start" + minimal + > + + + +
+ ); +} diff --git a/packages/webapp/src/containers/Import/ImportStepper.module.scss b/packages/webapp/src/containers/Import/ImportStepper.module.scss new file mode 100644 index 000000000..2971a3632 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportStepper.module.scss @@ -0,0 +1,18 @@ +.content { + margin-top: 0; + margin-bottom: 0; + border-top: 1px solid #DCE0E5; +} + +.root { + +} + +.items { + padding: 32px 20px; + min-width: 660px; + max-width: 760px; + width: 75%; + margin-left: auto; + margin-right: auto; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Import/ImportStepper.tsx b/packages/webapp/src/containers/Import/ImportStepper.tsx new file mode 100644 index 000000000..404f715d3 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportStepper.tsx @@ -0,0 +1,34 @@ +// @ts-nocheck + +import { Stepper } from '@/components/Stepper'; +import { ImportFileUploadStep } from './ImportFileUploadStep'; +import { useImportFileContext } from './ImportFileProvider'; +import { ImportFileMapping } from './ImportFileMapping'; +import { ImportFilePreview } from './ImportFilePreview'; +import styles from './ImportStepper.module.scss'; + +export function ImportStepper() { + const { step } = useImportFileContext(); + + return ( + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/Import/ImportView.module.scss b/packages/webapp/src/containers/Import/ImportView.module.scss new file mode 100644 index 000000000..867462f16 --- /dev/null +++ b/packages/webapp/src/containers/Import/ImportView.module.scss @@ -0,0 +1,6 @@ +.root{ + +} +.rootWrap { + max-width: 1800px; +} \ No newline at end of file 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/_types.ts b/packages/webapp/src/containers/Import/_types.ts new file mode 100644 index 000000000..0d7856819 --- /dev/null +++ b/packages/webapp/src/containers/Import/_types.ts @@ -0,0 +1,5 @@ +export enum ImportStepperStep { + Upload = 0, + Mapping = 1, + Preview = 2, +} 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} />