diff --git a/client/package.json b/client/package.json index 43e6e59e3..e0e0ae294 100644 --- a/client/package.json +++ b/client/package.json @@ -56,6 +56,7 @@ "lodash": "^4.17.15", "mini-css-extract-plugin": "0.9.0", "moment": "^2.24.0", + "moment-timezone": "^0.5.33", "node-sass": "^4.13.1", "optimize-css-assets-webpack-plugin": "5.0.3", "pnp-webpack-plugin": "1.6.0", diff --git a/client/src/common/currencies.js b/client/src/common/currencies.js index b47f16807..4a1f0c9ad 100644 --- a/client/src/common/currencies.js +++ b/client/src/common/currencies.js @@ -1,7 +1,23 @@ import intl from 'react-intl-universal'; +import currencies from 'js-money/lib/currency'; +import { sortBy } from 'lodash'; export const getCurrencies = () => [ { name: intl.get('us_dollar'), code: 'USD' }, { name: intl.get('euro'), code: 'EUR' }, { name: intl.get('libyan_diner'), code: 'LYD' }, ]; + +export const getAllCurrenciesOptions = () => { + const codes = Object.keys(currencies); + const sortedCodes = sortBy(codes); + + return sortedCodes.map((code) => { + const currency = currencies[code]; + + return { + key: code, + name: `${code} - ${currency.name}`, + }; + }); +}; diff --git a/client/src/common/fiscalYearOptions.js b/client/src/common/fiscalYearOptions.js index 9fd5c3845..14d887946 100644 --- a/client/src/common/fiscalYearOptions.js +++ b/client/src/common/fiscalYearOptions.js @@ -2,63 +2,51 @@ import intl from 'react-intl-universal'; export const getFiscalYear = () => [ { - id: 0, name: `${intl.get('january')} - ${intl.get('december')}`, - value: 'january', + key: 'january', }, { - id: 1, name: `${intl.get('february')} - ${intl.get('january')}`, - value: 'february', + key: 'february', }, { - id: 2, name: `${intl.get('march')} - ${intl.get('february')}`, - value: 'March', + key: 'march', }, { - id: 3, name: `${intl.get('april')} - ${intl.get('march')}`, - value: 'april', + key: 'april', }, { - id: 4, name: `${intl.get('may')} - ${intl.get('april')}`, - value: 'may', + key: 'may', }, { - id: 5, name: `${intl.get('june')} - ${intl.get('may')}`, - value: 'june', + key: 'june', }, { - id: 6, name: `${intl.get('july')} - ${intl.get('june')}`, - value: 'july', + key: 'july', }, { - id: 7, name: `${intl.get('august')} - ${intl.get('july')}`, - value: 'August', + key: 'august', }, { - id: 8, name: `${intl.get('september')} - ${intl.get('august')}`, - value: 'september', + key: 'september', }, { - id: 9, name: `${intl.get('october')} - ${intl.get('november')}`, - value: 'october', + key: 'october', }, { - id: 10, name: `${intl.get('november')} - ${intl.get('october')}`, - value: 'november', + key: 'november', }, { - id: 11, name: `${intl.get('december')} - ${intl.get('november')}`, - value: 'december', + key: 'december', }, ] \ No newline at end of file diff --git a/client/src/containers/Setup/SetupOrganization.schema.js b/client/src/containers/Setup/SetupOrganization.schema.js index b7d4c93ad..3517eb6a0 100644 --- a/client/src/containers/Setup/SetupOrganization.schema.js +++ b/client/src/containers/Setup/SetupOrganization.schema.js @@ -4,10 +4,12 @@ import intl from 'react-intl-universal'; // Retrieve the setup organization form validation. export const getSetupOrganizationValidation = () => Yup.object().shape({ - organization_name: Yup.string() + organizationName: Yup.string() .required() .label(intl.get('organization_name_')), - financialDateStart: Yup.date().required().label(intl.get('date_start_')), + location: Yup.string() + .required() + .label(intl.get('setup.organization.location')), baseCurrency: Yup.string().required().label(intl.get('base_currency_')), language: Yup.string().required().label(intl.get('language')), fiscalYear: Yup.string().required().label(intl.get('fiscal_year_')), diff --git a/client/src/containers/Setup/SetupOrganizationForm.js b/client/src/containers/Setup/SetupOrganizationForm.js index 423602d45..e09501a03 100644 --- a/client/src/containers/Setup/SetupOrganizationForm.js +++ b/client/src/containers/Setup/SetupOrganizationForm.js @@ -7,24 +7,18 @@ import { InputGroup, MenuItem, Classes, - Position, } from '@blueprintjs/core'; -import { DateInput } from '@blueprintjs/datetime'; import classNames from 'classnames'; import { TimezonePicker } from '@blueprintjs/timezone'; import { FormattedMessage as T } from 'components'; +import { getCountries } from 'common/countries'; import { Col, Row, ListSelect } from 'components'; -import { - momentFormatter, - tansformDateValue, - inputIntent, - handleDateChange, -} from 'utils'; +import { inputIntent } from 'utils'; import { getFiscalYear } from 'common/fiscalYearOptions'; import { getLanguages } from 'common/languagesOptions'; -import { getCurrencies } from 'common/currencies'; +import { getAllCurrenciesOptions } from 'common/currencies'; /** * Setup organization form. @@ -32,7 +26,8 @@ import { getCurrencies } from 'common/currencies'; export default function SetupOrganizationForm({ isSubmitting, values }) { const FiscalYear = getFiscalYear(); const Languages = getLanguages(); - const Currencies = getCurrencies(); + const currencies = getAllCurrenciesOptions(); + const countries = getCountries(); return (
@@ -41,40 +36,41 @@ export default function SetupOrganizationForm({ isSubmitting, values }) { {/* ---------- Organization name ---------- */} - + {({ form, field, meta: { error, touched } }) => ( } className={'form-group--name'} intent={inputIntent({ error, touched })} - helperText={} + helperText={} > )} - {/* ---------- Financial starting date ---------- */} - - {({ - form: { setFieldValue }, - field: { value }, - meta: { error, touched }, - }) => ( + {/* ---------- Location ---------- */} + + {({ form, field: { value }, meta: { error, touched } }) => ( } + label={} + className={classNames( + 'form-group--business-location', + Classes.FILL, + )} + helperText={} intent={inputIntent({ error, touched })} - helperText={} - className={classNames('form-group--select-list', Classes.FILL)} > - { - setFieldValue('financialDateStart', formattedDate); - })} - intent={inputIntent({ error, touched })} + { + form.setFieldValue('location', value); + }} + selectedItem={value} + selectedItemProp={'value'} + defaultText={} + textProp={'name'} + popoverProps={{ minimal: true }} /> )} @@ -100,15 +96,15 @@ export default function SetupOrganizationForm({ isSubmitting, values }) { helperText={} > } /> } popoverProps={{ minimal: true }} onItemSelect={(item) => { - setFieldValue('baseCurrency', item.code); + setFieldValue('baseCurrency', item.key); }} - selectedItemProp={'code'} + selectedItemProp={'key'} textProp={'name'} defaultText={} selectedItem={value} @@ -181,12 +177,12 @@ export default function SetupOrganizationForm({ isSubmitting, values }) { } /> } selectedItem={value} - selectedItemProp={'value'} + selectedItemProp={'key'} textProp={'name'} defaultText={} popoverProps={{ minimal: true }} onItemSelect={(item) => { - setFieldValue('fiscalYear', item.value); + setFieldValue('fiscalYear', item.key); }} filterable={false} /> diff --git a/client/src/containers/Setup/SetupOrganizationPage.js b/client/src/containers/Setup/SetupOrganizationPage.js index a21aec2f6..f81cb8173 100644 --- a/client/src/containers/Setup/SetupOrganizationPage.js +++ b/client/src/containers/Setup/SetupOrganizationPage.js @@ -1,6 +1,5 @@ import React from 'react'; import { Formik } from 'formik'; -import moment from 'moment'; import { FormattedMessage as T } from 'components'; import 'style/pages/Setup/Organization.scss'; @@ -9,15 +8,14 @@ import SetupOrganizationForm from './SetupOrganizationForm'; import { useOrganizationSetup } from 'hooks/query'; import withSettingsActions from 'containers/Settings/withSettingsActions'; -import withOrganizationActions from 'containers/Organization/withOrganizationActions'; import { compose, transfromToSnakeCase } from 'utils'; import { getSetupOrganizationValidation } from './SetupOrganization.schema'; // Initial values. const defaultValues = { - organization_name: '', - financialDateStart: moment(new Date()).format('YYYY-MM-DD'), + organizationName: '', + location: 'libya', baseCurrency: '', language: 'en', fiscalYear: '', @@ -27,7 +25,7 @@ const defaultValues = { /** * Setup organization form. */ -function SetupOrganizationPage({ wizard, setOrganizationSetupCompleted }) { +function SetupOrganizationPage({ wizard }) { const { mutateAsync: organizationSetupMutate } = useOrganizationSetup(); // Validation schema. @@ -73,5 +71,4 @@ function SetupOrganizationPage({ wizard, setOrganizationSetupCompleted }) { export default compose( withSettingsActions, - withOrganizationActions, )(SetupOrganizationPage); diff --git a/client/src/lang/ar-ly/index.json b/client/src/lang/ar-ly/index.json index 998be834f..8f9099da4 100644 --- a/client/src/lang/ar-ly/index.json +++ b/client/src/lang/ar-ly/index.json @@ -1043,11 +1043,8 @@ "total_rows": "", "always": "دائماً", "none": "", - "us_dollar": "", - "euro": "", - "libyan_diner": "", - "english": "", - "arabic": "", + "english": "English", + "arabic": "العربية", "just_a_moment_we_re_calculating_your_cost_transactions": "لحظة واحدة! نحن نحسب معاملات التكلفة الخاصة بك ، هذا لا يستغرق الكثير من الوقت ، يرجى التحقق بعد فترة.", "refresh": "", "total_name": "إجمالي {name}", @@ -1280,6 +1277,7 @@ "billing.suspend_message.title": "حسابك معلق :(", "billing.suspend_message.description": "تم تعليق حسابك بسبب انتهاء فترة الاشتراك. الرجاء تجديد الاشتراك لتفعيل الحساب.", "dashboard.subscription_msg.period_over": "انتهت فترة الاشتراك", - "inventory_adjustment.details_drawer.title": "تفاصيل معاملة تسوية المخزون" + "inventory_adjustment.details_drawer.title": "تفاصيل معاملة تسوية المخزون", + "setup.organization.location": "الموقع" } diff --git a/client/src/lang/en/index.js b/client/src/lang/en/index.js index 3341432e6..1e7845a3c 100644 --- a/client/src/lang/en/index.js +++ b/client/src/lang/en/index.js @@ -353,7 +353,7 @@ export default { once_delete_this_exchange_rate_you_will_able_to_restore_it: `Once you delete this exchange rate, you won\'t be able to restore it later. Are you sure you want to delete this exchange rate?`, once_delete_these_exchange_rates_you_will_not_able_restore_them: `Once you delete these exchange rates, you won't be able to retrieve them later. Are you sure you want to delete them?`, once_delete_this_item_category_you_will_able_to_restore_it: `Once you delete this category, you won\'t be able to restore it later. Are you sure you want to delete this item?`, - select_business_location: 'Select Business Location', + select_business_location: 'Select business location', select_base_currency: 'Select base currency', select_fiscal_year: 'Select fiscal year', select_language: 'Select Language', diff --git a/client/src/lang/en/index.json b/client/src/lang/en/index.json index e5b836a1a..32bca45cc 100644 --- a/client/src/lang/en/index.json +++ b/client/src/lang/en/index.json @@ -1248,5 +1248,6 @@ "billing.suspend_message.title": "Your account is suspended :(", "billing.suspend_message.description": "Your account has been suspended due to the expiration of the subscription period. Please renew the subscription to activate the account.", "dashboard.subscription_msg.period_over": "Subscription period is over", - "inventory_adjustment.details_drawer.title": "Inventory adjustment details" + "inventory_adjustment.details_drawer.title": "Inventory adjustment details", + "setup.organization.location": "Location" } \ No newline at end of file diff --git a/client/src/style/pages/Setup/Organization.scss b/client/src/style/pages/Setup/Organization.scss index b694ce993..1876d81f3 100644 --- a/client/src/style/pages/Setup/Organization.scss +++ b/client/src/style/pages/Setup/Organization.scss @@ -19,8 +19,8 @@ form { h3 { - color: #4e5764; - margin-bottom: 1.2rem; + color: #6b7382; + margin-bottom: 1.6rem; font-weight: 500; } } @@ -41,11 +41,7 @@ } label.bp3-label{ - color: #313744; - } - - .form-group--language { - margin-left: 10px; + color: #20242e; } .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) { diff --git a/server/src/api/controllers/Organization.ts b/server/src/api/controllers/Organization.ts index 944593248..933a4312f 100644 --- a/server/src/api/controllers/Organization.ts +++ b/server/src/api/controllers/Organization.ts @@ -1,4 +1,5 @@ import { Inject, Service } from 'typedi'; +import moment from 'moment-timezone'; import { Router, Request, Response, NextFunction } from 'express'; import { check, ValidationChain } from 'express-validator'; @@ -8,11 +9,16 @@ import TenancyMiddleware from 'api/middleware/TenancyMiddleware'; import SubscriptionMiddleware from 'api/middleware/SubscriptionMiddleware'; import AttachCurrentTenantUser from 'api/middleware/AttachCurrentTenantUser'; import OrganizationService from 'services/Organization'; +import { + ACCEPTED_CURRENCIES, + MONTHS, + ACCEPTED_LOCALES, + DATE_FORMATS, +} from 'services/Organization/constants'; + import { ServiceError } from 'exceptions'; import BaseController from 'api/controllers/BaseController'; -const DATE_FORMATS = ['MM/DD/YYYY', 'M/D/YYYY']; -const BASE_CURRENCY = ['USD', 'LYD']; @Service() export default class OrganizationController extends BaseController { @Inject() @@ -40,6 +46,8 @@ export default class OrganizationController extends BaseController { ); router.put( '/', + this.buildValidationSchema, + this.validationResult, this.asyncMiddleware(this.updateOrganization.bind(this)), this.handleServiceErrors.bind(this) ); @@ -57,10 +65,11 @@ export default class OrganizationController extends BaseController { private get buildValidationSchema(): ValidationChain[] { return [ check('organization_name').exists().trim(), - check('base_currency').exists().isIn(BASE_CURRENCY), - check('timezone').exists(), - check('fiscal_year').exists(), + check('base_currency').exists().isIn(ACCEPTED_CURRENCIES), + check('timezone').exists().isIn(moment.tz.names()), + check('fiscal_year').exists().isIn(MONTHS), check('industry').optional().isString(), + check('language').optional().isString().isIn(ACCEPTED_LOCALES), check('date_format').optional().isIn(DATE_FORMATS), ]; } @@ -80,7 +89,6 @@ export default class OrganizationController extends BaseController { tenantId, buildDTO ); - return res.status(200).send({ type: 'success', code: 'ORGANIZATION.DATABASE.INITIALIZED', @@ -117,10 +125,10 @@ export default class OrganizationController extends BaseController { /** * Update the organization information. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - * @returns + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns */ private async updateOrganization( req: Request, diff --git a/server/src/services/Organization/constants.ts b/server/src/services/Organization/constants.ts new file mode 100644 index 000000000..74e30d0ca --- /dev/null +++ b/server/src/services/Organization/constants.ts @@ -0,0 +1,34 @@ +import currencies from 'js-money/lib/currency'; + +export const DATE_FORMATS = [ + 'MM.dd.yy', + 'dd.MM.yy', + 'yy.MM.dd', + 'MM.dd.yyyy', + 'dd.MM.yyyy', + 'yyyy.MM.dd', + 'MM/DD/YYYY', + 'M/D/YYYY', + 'dd MMM YYYY', + 'dd MMMM YYYY', + 'MMMM dd, YYYY', + 'EEE, MMMM dd, YYYY', +]; +export const ACCEPTED_CURRENCIES = Object.keys(currencies); + +export const MONTHS = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', +]; + +export const ACCEPTED_LOCALES = ['en', 'ar']; diff --git a/server/src/services/Organization/index.ts b/server/src/services/Organization/index.ts index 47eaae794..3e8e45ea6 100644 --- a/server/src/services/Organization/index.ts +++ b/server/src/services/Organization/index.ts @@ -1,20 +1,17 @@ import { Service, Inject } from 'typedi'; -import { Container } from 'typedi'; -// import { ObjectId } from 'mongoose'; import { ServiceError } from 'exceptions'; -import { IOrganizationBuildDTO, ISystemUser, ITenant } from 'interfaces'; +import { + IOrganizationBuildDTO, + IOrganizationUpdateDTO, + ITenant, +} from 'interfaces'; import { EventDispatcher, EventDispatcherInterface, } from 'decorators/eventDispatcher'; import events from 'subscribers/events'; -import { - TenantAlreadyInitialized, - TenantAlreadySeeded, - TenantDatabaseNotBuilt, -} from 'exceptions'; import TenantsManager from 'services/Tenancy/TenantsManager'; -import { Tenant, TenantMetadata } from 'system/models'; +import { Tenant } from 'system/models'; import { ObjectId } from 'mongodb'; const ERRORS = { @@ -60,10 +57,13 @@ export default class OrganizationService { await this.tenantsManager.createDatabase(tenant); // Migrate the tenant. - const migratedTenant = await this.tenantsManager.migrateTenant(tenant); + await this.tenantsManager.migrateTenant(tenant); + + // Migrated tenant. + const migratedTenant = await tenant.$query(); // Seed tenant. - const seededTenant = await this.tenantsManager.seedTenant(migratedTenant); + await this.tenantsManager.seedTenant(migratedTenant); // Markes the tenant as completed builing. await Tenant.markAsBuilt(tenantId); @@ -71,7 +71,7 @@ export default class OrganizationService { // Throws `onOrganizationBuild` event. this.eventDispatcher.dispatch(events.organization.build, { - tenant: seededTenant, + tenantId: tenant.id, }); } @@ -91,13 +91,14 @@ export default class OrganizationService { this.throwIfTenantIsBuilding(tenant); // Saves the tenant metadata. - await this.saveTenantMetadata(tenant, buildDTO); + await tenant.saveMetadata(buildDTO); // Send welcome mail to the user. const jobMeta = await this.agenda.now('organization-setup', { tenantId, buildDTO, }); + // Transformes the mangodb id to string. const jobId = new ObjectId(jobMeta.attrs._id).toString(); // Markes the tenant as currently building. @@ -109,12 +110,11 @@ export default class OrganizationService { }; } - throwIfTenantIsBuilding(tenant) { - if (tenant.buildJobId) { - throw new ServiceError(ERRORS.TENANT_IS_BUILDING); - } - } - + /** + * Unlocks tenant build run job. + * @param {number} tenantId + * @param {number} jobId + */ public async revertBuildRunJob(tenantId: number, jobId: string) { await Tenant.markAsBuildCompleted(tenantId, jobId); } @@ -135,6 +135,23 @@ export default class OrganizationService { return tenant; } + /** + * Updates organization information. + * @param {ITenant} tenantId + * @param {IOrganizationUpdateDTO} organizationDTO + */ + public async updateOrganization( + tenantId: number, + organizationDTO: IOrganizationUpdateDTO + ): Promise { + const tenant = await Tenant.query().findById(tenantId); + + // Throw error if the tenant not exists. + this.throwIfTenantNotExists(tenant); + + await tenant.saveMetadata(organizationDTO); + } + /** * Throws error in case the given tenant is undefined. * @param {ITenant} tenant @@ -156,16 +173,13 @@ export default class OrganizationService { } /** - * Saves the organization metadata. - * @param tenant - * @param buildDTO - * @returns + * Throw error if the tenant is building. + * @param {ITenant} tenant */ - private saveTenantMetadata(tenant: ITenant, buildDTO) { - return TenantMetadata.query().insert({ - tenantId: tenant.id, - ...buildDTO, - }); + private throwIfTenantIsBuilding(tenant) { + if (tenant.buildJobId) { + throw new ServiceError(ERRORS.TENANT_IS_BUILDING); + } } /** diff --git a/server/src/system/migrations/20200823235340_create_tenants_metadata_table.js b/server/src/system/migrations/20200823235340_create_tenants_metadata_table.js index ef3fd6332..adbe4e774 100644 --- a/server/src/system/migrations/20200823235340_create_tenants_metadata_table.js +++ b/server/src/system/migrations/20200823235340_create_tenants_metadata_table.js @@ -7,6 +7,7 @@ exports.up = function (knex) { table.string('industry'); table.string('base_currency'); + table.string('language'); table.string('timezone'); table.string('date_format');