diff --git a/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.less b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.less new file mode 100644 index 000000000..2060a2326 --- /dev/null +++ b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.less @@ -0,0 +1,172 @@ +// +// CompactSettingsConfig.less +// CompactConnect +// +// Created by InspiringApps on 5/13/2025. +// + +.compact-config { + .loading-container { + width: 100%; + height: 40rem; + } + + .loading-error-container { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + min-height: 20rem; + color: @midRed; + + .general-message { + font-weight: @fontWeightBold; + text-align: center; + } + + .response-message { + margin-top: 2.4rem; + text-align: center; + } + } + + .compact-config-form { + width: 100%; + } + + .form-section-title { + margin-bottom: 1.2rem; + + &.notifications, + &.live-status { + padding-top: 1.2rem; + border-top: 1px solid @lightGrey; + } + } + + .confirm-config-modal { + :deep(.modal-container) { + width: 95%; + max-width: 60rem; + padding: 2rem; + + @media @tabletWidth { + padding: 4rem; + } + + .modal-content { + padding-top: 0.6rem; + + .modal-error { + margin-top: 2.4rem; + color: @midRed; + text-align: right; + } + + .action-button-row { + display: flex; + flex-direction: column; + margin-top: 2rem; + + @media @desktopWidth { + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-end; + margin-top: 4rem; + } + + .action-button { + margin-bottom: 1rem; + + @media @desktopWidth { + &:not(:first-child) { + margin-left: 2.4rem; + } + } + + .input-button, + .input-submit { + width: 100%; + + @media @desktopWidth { + width: auto; + } + } + } + } + } + } + } + + .form-row { + flex-direction: row; + flex-wrap: wrap; + width: 100%; + + :deep(.form-field-error) { + flex-basis: 100%; + text-align: right; + } + + &.currency { + :deep(label) { + flex-basis: calc(100% - 12rem); + padding-right: 30px; + } + + :deep(input[type=text]) { + align-self: center; + width: 10rem; + height: 4rem; + margin-left: auto; + + & + .separator::before { + position: absolute; + top: 50%; + left: -11.6rem; + width: 30px; + font-weight: @fontWeightBold; + transform: translateY(-50%); + content: '$'; + } + } + + :deep(.form-field-error) { + text-align: right; + } + } + + &.radio-group-container { + :deep(.input-label) { + flex-basis: 60%; + } + + :deep(.input-label-subtext) { + flex-basis: 70%; + + @media @tabletWidth { + flex-basis: 55%; + } + } + + :deep(.radio-button-group-container) { + margin-left: auto; + } + } + } + + .live-status-radio:not(.disabled) { + padding: 0.8rem 0.8rem 0.4rem 0.8rem; + border: 1px solid @midRed; + border-radius: 4px; + } + + .btn-catch-email-lists { + .visually-hidden(); + } + + .compact-config-submit { + align-items: flex-end; + margin-top: 4rem; + } +} diff --git a/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.spec.ts b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.spec.ts new file mode 100644 index 000000000..a9465bcc0 --- /dev/null +++ b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.spec.ts @@ -0,0 +1,19 @@ +// +// CompactSettingsConfig.spec.ts +// CompactConnect +// +// Created by InspiringApps on 5/13/2025. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import CompactSettingsConfig from '@components/CompactSettingsConfig/CompactSettingsConfig.vue'; + +describe('CompactSettingsConfig component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(CompactSettingsConfig); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(CompactSettingsConfig).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.ts b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.ts new file mode 100644 index 000000000..3e2384945 --- /dev/null +++ b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.ts @@ -0,0 +1,347 @@ +// +// CompactSettingsConfig.ts +// CompactConnect +// +// Created by InspiringApps on 5/13/2025. +// + +import { + Component, + mixins, + Watch, + toNative +} from 'vue-facing-decorator'; +import { + reactive, + computed, + ComputedRef, + nextTick +} from 'vue'; +import MixinForm from '@components/Forms/_mixins/form.mixin'; +import Card from '@components/Card/Card.vue'; +import LoadingSpinner from '@components/LoadingSpinner/LoadingSpinner.vue'; +import InputText from '@components/Forms/InputText/InputText.vue'; +import InputEmailList from '@components/Forms/InputEmailList/InputEmailList.vue'; +import InputRadioGroup from '@components/Forms/InputRadioGroup/InputRadioGroup.vue'; +import InputButton from '@components/Forms/InputButton/InputButton.vue'; +import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; +import Modal from '@components/Modal/Modal.vue'; +import MockPopulate from '@components/Forms/MockPopulate/MockPopulate.vue'; +import { CompactType, CompactConfig, FeeType } from '@models/Compact/Compact.model'; +import { StaffUser } from '@models/StaffUser/StaffUser.model'; +import { FormInput } from '@models/FormInput/FormInput.model'; +import { formatCurrencyInput, formatCurrencyBlur } from '@models/_formatters/currency'; +import { dataApi } from '@network/data.api'; +import Joi from 'joi'; + +interface RadioOption { + value: boolean; + name: string | ComputedRef; +} + +@Component({ + name: 'CompactSettingsConfig', + components: { + Card, + LoadingSpinner, + MockPopulate, + InputText, + InputEmailList, + InputRadioGroup, + InputButton, + InputSubmit, + Modal, + } +}) +class CompactSettingsConfig extends mixins(MixinForm) { + // + // Data + // + isLoading = false; + loadingErrorMessage = ''; + initialCompactConfig: any = {}; + isRegistrationEnabledInitialValue = false; + isConfirmConfigModalDisplayed = false; + + // + // Lifecycle + // + async created() { + await this.init(); + } + + // + // Computed + // + get userStore() { + return this.$store.state.user; + } + + get compactType(): CompactType | null { + return this.userStore.currentCompact?.type; + } + + get user(): StaffUser | null { + return this.userStore.model; + } + + get submitLabel(): string { + return (this.isFormLoading) ? this.$t('common.loading') : this.$t('common.saveChanges'); + } + + get isMockPopulateEnabled(): boolean { + return Boolean(this.$envConfig.isDevelopment); + } + + // + // Methods + // + async init(): Promise { + this.shouldValuesIncludeDisabled = true; + this.isLoading = true; + + if (this.compactType) { + // Fetch compact config + await this.getCompactConfig(); + // Initialize the form + this.initFormInputs(); + + // Format existing values + const { compactFee, privilegeTransactionFee } = this.formData; + + if (compactFee?.value) { + this.formatBlur(this.formData.compactFee); + } + + if (privilegeTransactionFee?.value) { + this.formatBlur(this.formData.privilegeTransactionFee, true); + } + } + } + + async getCompactConfig(): Promise { + const compact = this.compactType || ''; + const compactConfig = await dataApi.getCompactConfig(compact).catch((err) => { + this.loadingErrorMessage = err?.message || this.$t('serverErrors.networkError'); + }); + + this.initialCompactConfig = compactConfig; + this.isRegistrationEnabledInitialValue = this.initialCompactConfig?.licenseeRegistrationEnabled; + this.isLoading = false; + } + + initFormInputs(): void { + this.formData = reactive({ + compactFee: new FormInput({ + id: 'compact-fee', + name: 'compact-fee', + label: computed(() => this.$t('compact.compactFee')), + validation: Joi.number().required().min(0).messages(this.joiMessages.currency), + value: this.initialCompactConfig?.compactCommissionFee?.feeAmount, + }), + privilegeTransactionFee: new FormInput({ + id: 'privilege-transaction-fee', + name: 'privilege-transaction-fee', + label: computed(() => this.$t('compact.privilegeTransactionFee')), + validation: Joi.number().min(0).messages(this.joiMessages.currency), + value: this.initialCompactConfig?.transactionFeeConfiguration?.licenseeCharges?.chargeAmount, + }), + opsNotificationEmails: new FormInput({ + id: 'ops-notification-emails', + name: 'ops-notification-emails', + label: computed(() => this.$t('compact.opsNotificationEmails')), + labelSubtext: computed(() => this.$t('compact.opsNotificationEmailsSubtext')), + placeholder: computed(() => this.$t('compact.addEmails')), + validation: Joi.array().min(1).messages(this.joiMessages.array), + value: this.initialCompactConfig?.compactOperationsTeamEmails || [], + }), + adverseActionNotificationEmails: new FormInput({ + id: 'adverse-action-notification-emails', + name: 'adverse-action-notification-emails', + label: computed(() => this.$t('compact.adverseActionsNotificationEmails')), + labelSubtext: computed(() => this.$t('compact.adverseActionsNotificationEmailsSubtext')), + placeholder: computed(() => this.$t('compact.addEmails')), + validation: Joi.array().min(1).messages(this.joiMessages.array), + value: this.initialCompactConfig?.compactAdverseActionsNotificationEmails || [], + }), + summaryReportNotificationEmails: new FormInput({ + id: 'summary-report-notification-emails', + name: 'summary-report-notification-emails', + label: computed(() => this.$t('compact.summaryReportEmails')), + labelSubtext: computed(() => this.$t('compact.summaryReportEmailsSubtext')), + placeholder: computed(() => this.$t('compact.addEmails')), + validation: Joi.array().min(1).messages(this.joiMessages.array), + value: this.initialCompactConfig?.compactSummaryReportNotificationEmails || [], + }), + isRegistrationEnabled: new FormInput({ + id: 'registration-enabled', + name: 'registration-enabled', + label: computed(() => this.$t('compact.licenseRegistrationEnabled')), + labelSubtext: computed(() => this.$t('compact.licenseRegistrationEnabledSubtext')), + validation: Joi.boolean().required().messages(this.joiMessages.boolean), + valueOptions: [ + { value: true, name: computed(() => this.$t('common.yes')) }, + { value: false, name: computed(() => this.$t('common.no')) }, + ] as Array, + value: this.initialCompactConfig?.licenseeRegistrationEnabled || false, + isDisabled: computed(() => this.isRegistrationEnabledInitialValue), + }), + submit: new FormInput({ + isSubmitInput: true, + id: 'submit-compact-settings', + }), + }); + this.watchFormInputs(); // Important if you want automated form validation + } + + formatInput(formInput: FormInput): void { + const { value } = formInput; + const formatted = formatCurrencyInput(value); + + // Update input value + formInput.value = formatted; + } + + formatBlur(formInput: FormInput, isOptional = false): void { + const { value } = formInput; + const formatted = formatCurrencyBlur(value, isOptional); + + // Update input value + formInput.value = formatted; + // Validate as touched + formInput.isTouched = true; + formInput.validate(); + } + + async handleSubmit(isConfirmed = false): Promise { + this.populateOptionalMissing(); + this.validateAll({ asTouched: true }); + + if (this.isFormValid) { + const { isRegistrationEnabled } = this.formValues; + + if (isRegistrationEnabled && !this.isRegistrationEnabledInitialValue && !isConfirmed) { + this.openConfirmConfigModal(); + } else { + this.startFormLoading(); + await this.processConfigUpdate(); + this.endFormLoading(); + } + } + } + + async processConfigUpdate(): Promise { + const compact = this.compactType || ''; + const { + compactFee, + privilegeTransactionFee, + opsNotificationEmails, + adverseActionNotificationEmails, + summaryReportNotificationEmails, + isRegistrationEnabled, + } = this.formValues; + const payload: CompactConfig = { + compactCommissionFee: { + feeType: FeeType.FLAT_RATE, + feeAmount: Number(compactFee), + }, + compactOperationsTeamEmails: opsNotificationEmails, + compactAdverseActionsNotificationEmails: adverseActionNotificationEmails, + compactSummaryReportNotificationEmails: summaryReportNotificationEmails, + transactionFeeConfiguration: { + licenseeCharges: { + active: true, + chargeType: FeeType.FLAT_FEE_PER_PRIVILEGE, + chargeAmount: Number(privilegeTransactionFee), + }, + }, + licenseeRegistrationEnabled: isRegistrationEnabled, + }; + + // Call the server API to update + await dataApi.updateCompactConfig(compact, payload).catch((err) => { + this.setError(err.message); + }); + + // Handle success + if (!this.isFormError) { + if (Object.prototype.hasOwnProperty.call(payload, 'licenseeRegistrationEnabled')) { + this.isRegistrationEnabledInitialValue = (payload.licenseeRegistrationEnabled as boolean); + } + + this.isFormSuccessful = true; + this.updateFormSubmitSuccess(this.$t('compact.saveSuccessfulCompact')); + } + } + + populateMissingPrivilegeTransactionFee(): void { + if (this.formData.privilegeTransactionFee.value === '') { + this.populateFormInput(this.formData.privilegeTransactionFee, 0); + } + } + + populateMissingRegistrationEnabled(): void { + if (this.formData.isRegistrationEnabled.value === '') { + this.populateFormInput(this.formData.isRegistrationEnabled, false); + } + } + + populateOptionalMissing(): void { + this.populateMissingPrivilegeTransactionFee(); + this.populateMissingRegistrationEnabled(); + } + + async openConfirmConfigModal(): Promise { + this.isConfirmConfigModalDisplayed = true; + await nextTick(); + document.getElementById('confirm-modal-cancel-button')?.focus(); + } + + async closeConfirmConfigModal(): Promise { + this.isConfirmConfigModalDisplayed = false; + await nextTick(); + document.getElementById(this.formData.submit.id)?.focus(); + } + + focusTrapConfirmConfigModal(event: KeyboardEvent): void { + const firstTabIndex = document.getElementById('confirm-modal-submit-button'); + const lastTabIndex = document.getElementById('confirm-modal-cancel-button'); + + if (event.shiftKey) { + // shift + tab to last input + if (document.activeElement === firstTabIndex) { + lastTabIndex?.focus(); + event.preventDefault(); + } + } else if (document.activeElement === lastTabIndex) { + // Tab to first input + firstTabIndex?.focus(); + event.preventDefault(); + } + } + + async submitConfirmConfigModal(): Promise { + this.closeConfirmConfigModal(); + await this.handleSubmit(true); + } + + async mockPopulate(): Promise { + this.populateFormInput(this.formData.compactFee, 5.55); + this.populateFormInput(this.formData.privilegeTransactionFee, 5); + this.populateFormInput(this.formData.opsNotificationEmails, ['ops@example.com']); + this.populateFormInput(this.formData.adverseActionNotificationEmails, ['adverse@example.com']); + this.populateFormInput(this.formData.summaryReportNotificationEmails, ['summary@example.com']); + this.populateFormInput(this.formData.isRegistrationEnabled, true); + } + + // + // Watch + // + @Watch('compactType') fetchCompactConfig() { + this.init(); + } +} + +export default toNative(CompactSettingsConfig); + +// export default CompactSettingsConfig; diff --git a/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.vue b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.vue new file mode 100644 index 000000000..85d0bd7e6 --- /dev/null +++ b/webroot/src/components/CompactSettingsConfig/CompactSettingsConfig.vue @@ -0,0 +1,96 @@ + + + + + + diff --git a/webroot/src/components/Forms/InputButton/InputButton.ts b/webroot/src/components/Forms/InputButton/InputButton.ts index 2a417a000..db0761b78 100644 --- a/webroot/src/components/Forms/InputButton/InputButton.ts +++ b/webroot/src/components/Forms/InputButton/InputButton.ts @@ -20,6 +20,7 @@ class InputButton extends Vue { @Prop({ required: true }) private onClick?: () => void; @Prop({ default: '' }) private id?: string; @Prop({ default: true }) private isEnabled?: boolean; + @Prop({ default: false }) private isWarning?: boolean; @Prop({ default: false }) private shouldTransformText?: boolean; @Prop({ default: false }) private shouldHideMargin?: boolean; @Prop({ default: false }) private isTransparent?: boolean; diff --git a/webroot/src/components/Forms/InputButton/InputButton.vue b/webroot/src/components/Forms/InputButton/InputButton.vue index 70f01a2c9..a781bf67a 100644 --- a/webroot/src/components/Forms/InputButton/InputButton.vue +++ b/webroot/src/components/Forms/InputButton/InputButton.vue @@ -16,6 +16,7 @@ 'no-text-transform': !shouldTransformText, 'transparent': isTransparent, 'text-like': isTextLike, + 'warning': isWarning, }" :value="label" v-bind:disabled="isEnabled === false ? true : false" diff --git a/webroot/src/components/Forms/InputEmailList/InputEmailList.less b/webroot/src/components/Forms/InputEmailList/InputEmailList.less new file mode 100644 index 000000000..8d3e260ed --- /dev/null +++ b/webroot/src/components/Forms/InputEmailList/InputEmailList.less @@ -0,0 +1,63 @@ +// +// InputEmailList.less +// CompactConnect +// +// Created by InspiringApps on 5/13/2025. +// + +.input-container { + .input-row { + display: flex; + + .email-input { + flex-grow: 1; + padding-right: 80px; + } + + .add-email-help { + position: absolute; + top: 50%; + right: 8px; + width: 48px; + color: darken(@darkGrey, 10%); + font-size: 1.2rem; + text-align: center; + transform: translateY(-50%); + } + } + + .email-tag-container { + display: flex; + flex-wrap: wrap; + margin-top: 1.6rem; + + .email-tag { + display: flex; + align-items: center; + max-width: 100%; + margin-right: 1rem; + margin-bottom: 0.8rem; + padding-left: 1rem; + border-top-left-radius: ~"calc(@{borderRadiusPillShape} - 1999px)"; + border-top-right-radius: @borderRadiusPillShape; + border-bottom-right-radius: @borderRadiusPillShape; + border-bottom-left-radius: ~"calc(@{borderRadiusPillShape} - 1999px)"; + color: @white; + background-color: @fontColor; + + .email { + display: inline-block; + max-width: calc(100% - 3rem); + padding: 0.6rem 0; + word-break: break-word; + } + + .remove-email { + width: 3.2rem; + margin-left: auto; + cursor: pointer; + stroke: @white; + } + } + } +} diff --git a/webroot/src/components/Forms/InputEmailList/InputEmailList.spec.ts b/webroot/src/components/Forms/InputEmailList/InputEmailList.spec.ts new file mode 100644 index 000000000..1ae147383 --- /dev/null +++ b/webroot/src/components/Forms/InputEmailList/InputEmailList.spec.ts @@ -0,0 +1,19 @@ +// +// InputEmailList.spec.ts +// CompactConnect +// +// Created by InspiringApps on 5/13/2025. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import InputEmailList from '@components/Forms/InputEmailList/InputEmailList.vue'; + +describe('InputEmailList component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(InputEmailList); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(InputEmailList).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Forms/InputEmailList/InputEmailList.ts b/webroot/src/components/Forms/InputEmailList/InputEmailList.ts new file mode 100644 index 000000000..bedf1e4a5 --- /dev/null +++ b/webroot/src/components/Forms/InputEmailList/InputEmailList.ts @@ -0,0 +1,94 @@ +// +// InputEmailList.ts +// CompactConnect +// +// Created by InspiringApps on 5/13/2025. +// + +import { Component, mixins, toNative } from 'vue-facing-decorator'; +import MixinInput from '@components/Forms/_mixins/input.mixin'; +import CloseXIcon from '@components/Icons/CloseX/CloseX.vue'; +import Joi from 'joi'; + +@Component({ + name: 'InputEmailList', + components: { + CloseXIcon, + }, +}) +class InputEmailList extends mixins(MixinInput) { + // + // Data + // + inputValue = ''; + + // + // Computed + // + get shouldDisplayAddEmailHelp(): boolean { + return this.inputValue.length > 0; + } + + // + // Methods + // + validateInputValue(): void { + const { formInput, inputValue } = this; + + if (formInput.isTouched) { + const emailValue = inputValue.toLowerCase(); + const validation = Joi.string().email({ tlds: false }).messages({ + 'string.empty': this.$t('inputErrors.email'), + 'string.email': this.$t('inputErrors.email'), + }).validate(emailValue); + + if (validation.error) { + formInput.errorMessage = validation.error.message; + } else { + formInput.errorMessage = ''; + } + } + } + + input(): void { + this.validateInputValue(); + } + + add(): void { + const { formInput, inputValue, $refs } = this; + const emailInput = $refs.email as HTMLInputElement; + const emailValue = inputValue.toLowerCase(); + + formInput.isTouched = true; + this.validateInputValue(); + + if (!formInput.errorMessage && Array.isArray(formInput.value)) { + if (!formInput.value.includes(emailValue)) { + formInput.value.push(emailValue); + } + this.inputValue = ''; + formInput.validate(); + formInput.isTouched = false; + } + + emailInput.focus(); + } + + remove(emailToRemove): void { + const { formInput, $refs } = this; + const emailInput = $refs.email as HTMLInputElement; + + if (Array.isArray(formInput.value)) { + (formInput.value as Array) = formInput.value.filter((email) => email !== emailToRemove); + } + + formInput.isTouched = true; + formInput.validate(); + emailInput.focus(); + formInput.isTouched = false; + } +} + +export default toNative(InputEmailList); + +// export default InputEmailList; diff --git a/webroot/src/components/Forms/InputEmailList/InputEmailList.vue b/webroot/src/components/Forms/InputEmailList/InputEmailList.vue new file mode 100644 index 000000000..08c15a23a --- /dev/null +++ b/webroot/src/components/Forms/InputEmailList/InputEmailList.vue @@ -0,0 +1,79 @@ + + + + + + diff --git a/webroot/src/components/Forms/InputPassword/InputPassword.less b/webroot/src/components/Forms/InputPassword/InputPassword.less index 3822dea32..cbfa2c9c9 100644 --- a/webroot/src/components/Forms/InputPassword/InputPassword.less +++ b/webroot/src/components/Forms/InputPassword/InputPassword.less @@ -28,11 +28,12 @@ width: auto; min-width: 2.4rem; height: 100%; + padding-right: 0.4rem; cursor: pointer; svg { display: inline-block; - height: 80%; + height: 60%; fill: @darkGrey; } } diff --git a/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.less b/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.less index 966788b00..4f9b91859 100644 --- a/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.less +++ b/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.less @@ -10,6 +10,10 @@ flex-direction: column; margin-bottom: 1.0rem; + &.disabled .input-label { + color: darken(@darkGrey, 10%); + } + .radio-button-group-container { display: flex; flex-direction: column; @@ -57,6 +61,15 @@ cursor: pointer; content: ' '; } + + &.disabled { + color: darken(@darkGrey, 10%); + cursor: default; + + &::before { + cursor: default; + } + } } // Checked style @@ -68,8 +81,13 @@ width: 1.4rem; height: 1.4rem; border-radius: 50%; - background-color: @primaryColor; + background-color: @fontColor; content: ' '; } + + // Disabled checked style + input[type='radio']:disabled + label::after { + background-color: darken(@darkGrey, 10%); + } } } diff --git a/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.vue b/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.vue index 4e2a89bdb..2b385eb93 100644 --- a/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.vue +++ b/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.vue @@ -9,15 +9,24 @@
- {{ formInput.label }} - * +
+ {{ formInput.label }} + * +
+
+
diff --git a/webroot/src/components/Forms/InputText/InputText.vue b/webroot/src/components/Forms/InputText/InputText.vue index ed7931718..2637ae411 100644 --- a/webroot/src/components/Forms/InputText/InputText.vue +++ b/webroot/src/components/Forms/InputText/InputText.vue @@ -18,8 +18,16 @@ v-if="!formInput.shouldHideLabel" :for="formInput.id" > - {{ formInput.label }} - * +
+ {{ formInput.label }} + * +
+
+
+ { const formInput = new FormInput(); const component = wrapper.vm; - component.populateFormInput(formInput, null); + component.populateFormInput(formInput); expect(formInput.value).to.equal(''); }); diff --git a/webroot/src/components/Page/PageMainNav/PageMainNav.ts b/webroot/src/components/Page/PageMainNav/PageMainNav.ts index 5e8dcc0e1..a5ae54235 100644 --- a/webroot/src/components/Page/PageMainNav/PageMainNav.ts +++ b/webroot/src/components/Page/PageMainNav/PageMainNav.ts @@ -201,7 +201,7 @@ class PageMainNav extends Vue { params: { compact: this.currentCompact?.type }, label: computed(() => this.$t('navigation.compactSettings')), iconComponent: markRaw(SettingsIcon), - isEnabled: Boolean(this.currentCompact) && this.isCompactAdmin, + isEnabled: Boolean(this.currentCompact) && this.isAnyTypeOfAdmin, isExternal: false, isExactActive: false, }, diff --git a/webroot/src/components/PaymentProcessorConfig/PaymentProcessorConfig.less b/webroot/src/components/PaymentProcessorConfig/PaymentProcessorConfig.less index ad7a277a6..38b39632f 100644 --- a/webroot/src/components/PaymentProcessorConfig/PaymentProcessorConfig.less +++ b/webroot/src/components/PaymentProcessorConfig/PaymentProcessorConfig.less @@ -6,7 +6,17 @@ // .payment-processor { + .payment-processor-form { + width: 100%; + } + .payment-processor-form-container { margin-top: 3.2rem; } + + .payment-processer-submit { + :deep(.input-submit) { + width: 100%; + } + } } diff --git a/webroot/src/components/PaymentProcessorConfig/PaymentProcessorConfig.vue b/webroot/src/components/PaymentProcessorConfig/PaymentProcessorConfig.vue index 93909801f..faae93a10 100644 --- a/webroot/src/components/PaymentProcessorConfig/PaymentProcessorConfig.vue +++ b/webroot/src/components/PaymentProcessorConfig/PaymentProcessorConfig.vue @@ -23,6 +23,7 @@ :formInput="formData.submit" :label="submitLabel" :isEnabled="!isFormLoading" + class="payment-processer-submit" />
diff --git a/webroot/src/components/StateSettingsConfig/StateSettingsConfig.less b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.less new file mode 100644 index 000000000..5f45e4987 --- /dev/null +++ b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.less @@ -0,0 +1,178 @@ +// +// StateSettingsConfig.less +// CompactConnect +// +// Created by InspiringApps on 5/13/2025. +// + +.state-config { + .loading-container { + width: 100%; + height: 40rem; + } + + .loading-error-container { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + min-height: 20rem; + color: @midRed; + + .general-message { + font-weight: @fontWeightBold; + text-align: center; + } + + .response-message { + margin-top: 2.4rem; + text-align: center; + } + } + + .state-config-form { + width: 100%; + } + + .form-section-title { + margin-bottom: 1.2rem; + + &.jurisprudence, + &.notifications, + &.live-status { + padding-top: 1.2rem; + border-top: 1px solid @lightGrey; + } + } + + .confirm-config-modal { + :deep(.modal-container) { + width: 95%; + max-width: 60rem; + padding: 2rem; + + @media @tabletWidth { + padding: 4rem; + } + + .modal-content { + padding-top: 0.6rem; + + .modal-error { + margin-top: 2.4rem; + color: @midRed; + text-align: right; + } + + .action-button-row { + display: flex; + flex-direction: column; + margin-top: 2rem; + + @media @desktopWidth { + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-end; + margin-top: 4rem; + } + + .action-button { + margin-bottom: 1rem; + + @media @desktopWidth { + &:not(:first-child) { + margin-left: 2.4rem; + } + } + + .input-button, + .input-submit { + width: 100%; + + @media @desktopWidth { + width: auto; + } + } + } + } + } + } + } + + .form-row { + flex-direction: row; + flex-wrap: wrap; + width: 100%; + + :deep(.form-field-error) { + flex-basis: 100%; + } + + &.currency { + :deep(label) { + flex-basis: calc(100% - 12rem); + padding-right: 30px; + } + + :deep(input[type=text]) { + align-self: center; + width: 10rem; + height: 4rem; + margin-left: auto; + + & + .separator::before { + position: absolute; + top: 50%; + left: -11.6rem; + width: 30px; + font-weight: @fontWeightBold; + transform: translateY(-50%); + content: '$'; + } + } + + :deep(.form-field-error) { + text-align: right; + } + } + + &.jurisprudence-info-link { + :deep(input[type=text]) { + width: 100%; + } + } + + &.radio-group-container { + :deep(.input-label) { + flex-basis: 60%; + } + + :deep(.input-label-subtext) { + flex-basis: 70%; + + @media @tabletWidth { + flex-basis: 55%; + } + } + + :deep(.radio-button-group-container) { + margin-left: auto; + } + } + } + + .live-status-radio:not(.disabled) { + padding: 0.8rem 0.8rem 0.4rem 0.8rem; + border: 1px solid @midRed; + border-radius: 4px; + } + + .btn-catch-email-lists { + .visually-hidden(); + } + + .state-config-submit { + align-items: flex-end; + margin-top: 4rem; + } +} diff --git a/webroot/src/components/StateSettingsConfig/StateSettingsConfig.spec.ts b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.spec.ts new file mode 100644 index 000000000..64a737f93 --- /dev/null +++ b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.spec.ts @@ -0,0 +1,19 @@ +// +// StateSettingsConfig.spec.ts +// CompactConnect +// +// Created by InspiringApps on 5/13/2025. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import StateSettingsConfig from '@components/StateSettingsConfig/StateSettingsConfig.vue'; + +describe('StateSettingsConfig component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(StateSettingsConfig); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(StateSettingsConfig).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/StateSettingsConfig/StateSettingsConfig.ts b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.ts new file mode 100644 index 000000000..a20843163 --- /dev/null +++ b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.ts @@ -0,0 +1,401 @@ +// +// StateSettingsConfig.ts +// CompactConnect +// +// Created by InspiringApps on 5/13/2025. +// + +import { + Component, + mixins, + Prop, + Watch, + toNative +} from 'vue-facing-decorator'; +import { + reactive, + computed, + ComputedRef, + nextTick +} from 'vue'; +import MixinForm from '@components/Forms/_mixins/form.mixin'; +import Card from '@components/Card/Card.vue'; +import LoadingSpinner from '@components/LoadingSpinner/LoadingSpinner.vue'; +import InputText from '@components/Forms/InputText/InputText.vue'; +import InputEmailList from '@components/Forms/InputEmailList/InputEmailList.vue'; +import InputRadioGroup from '@components/Forms/InputRadioGroup/InputRadioGroup.vue'; +import InputButton from '@components/Forms/InputButton/InputButton.vue'; +import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; +import Modal from '@components/Modal/Modal.vue'; +import MockPopulate from '@components/Forms/MockPopulate/MockPopulate.vue'; +import { CompactType, CompactStateConfig } from '@models/Compact/Compact.model'; +import { StaffUser } from '@models/StaffUser/StaffUser.model'; +import { FormInput } from '@models/FormInput/FormInput.model'; +import { formatCurrencyInput, formatCurrencyBlur } from '@models/_formatters/currency'; +import { dataApi } from '@network/data.api'; +import Joi from 'joi'; + +interface RadioOption { + value: boolean; + name: string | ComputedRef; +} + +@Component({ + name: 'StateSettingsConfig', + components: { + Card, + LoadingSpinner, + MockPopulate, + InputText, + InputEmailList, + InputRadioGroup, + InputButton, + InputSubmit, + Modal, + }, +}) +class StateSettingsConfig extends mixins(MixinForm) { + @Prop({ required: true }) stateAbbrev!: string; + + // + // Data + // + isLoading = false; + loadingErrorMessage = ''; + initialStateConfig: CompactStateConfig | null = null; + feeInputs: Array = []; + isPurchaseEnabledInitialValue = false; + isConfirmConfigModalDisplayed = false; + + // + // Lifecycle + // + async created() { + await this.init(); + } + + // + // Computed + // + get userStore() { + return this.$store.state.user; + } + + get compactType(): CompactType | null { + return this.userStore?.currentCompact?.type || null; + } + + get user(): StaffUser | null { + return this.userStore?.model || null; + } + + get submitLabel(): string { + return (this.isFormLoading) ? this.$t('common.loading') : this.$t('common.saveChanges'); + } + + get isMockPopulateEnabled(): boolean { + return Boolean(this.$envConfig.isDevelopment); + } + + // + // Methods + // + async init(): Promise { + this.shouldValuesIncludeDisabled = true; + this.isLoading = true; + + if (this.compactType) { + // Fetch compact config + await this.getStateConfig(); + // Initialize the form + this.initFormInputs(); + } + } + + async getStateConfig(): Promise { + const compact = this.compactType || ''; + const licenseTypes = (this.$tm('licensing.licenseTypes') || []).filter((licenseType) => + licenseType.compactKey === compact); + const stateConfig: any = await dataApi.getCompactStateConfig(compact, this.stateAbbrev).catch((err) => { + this.loadingErrorMessage = err?.message || this.$t('serverErrors.networkError'); + }); + + if (!this.loadingErrorMessage) { + stateConfig?.privilegeFees?.forEach((privilegeFee) => { + const licenseType = licenseTypes.find((type) => type.abbrev === privilegeFee.licenseTypeAbbreviation); + + privilegeFee.name = licenseType?.name || privilegeFee.licenseTypeAbbreviation?.toUpperCase() || ''; + }); + this.initialStateConfig = stateConfig; + this.isPurchaseEnabledInitialValue = this.initialStateConfig?.licenseeRegistrationEnabled || false; + } + + this.isLoading = false; + } + + initFormInputs(): void { + this.formData = reactive({ + isJurisprudenceExamRequired: new FormInput({ + id: 'jurisprudence-exam-required', + name: 'jurisprudence-exam-required', + label: computed(() => this.$t('compact.jurisprudenceExamRequired')), + validation: Joi.boolean().required().messages(this.joiMessages.boolean), + valueOptions: [ + { value: true, name: computed(() => this.$t('common.yes')) }, + { value: false, name: computed(() => this.$t('common.no')) }, + ] as Array, + value: this.initialStateConfig?.jurisprudenceRequirements?.required || false, + }), + jurisprudenceInfoLink: new FormInput({ + id: 'jurisprudence-info-link', + name: 'jurisprudence-info-link', + label: computed(() => this.$t('compact.jurisprudenceInfoLink')), + labelSubtext: computed(() => this.$t('compact.jurisprudenceInfoLinkSubtext')), + placeholder: 'https://', + validation: Joi.string().uri().allow('').messages(this.joiMessages.string), + value: this.initialStateConfig?.jurisprudenceRequirements?.linkToDocumentation || '', + }), + opsNotificationEmails: new FormInput({ + id: 'ops-notification-emails', + name: 'ops-notification-emails', + label: computed(() => this.$t('compact.opsNotificationEmails')), + labelSubtext: computed(() => this.$t('compact.opsNotificationEmailsSubtext')), + placeholder: computed(() => this.$t('compact.addEmails')), + validation: Joi.array().min(1).messages(this.joiMessages.array), + value: this.initialStateConfig?.jurisdictionOperationsTeamEmails || [], + }), + adverseActionNotificationEmails: new FormInput({ + id: 'adverse-action-notification-emails', + name: 'adverse-action-notification-emails', + label: computed(() => this.$t('compact.adverseActionsNotificationEmails')), + labelSubtext: computed(() => this.$t('compact.adverseActionsNotificationEmailsSubtext')), + placeholder: computed(() => this.$t('compact.addEmails')), + validation: Joi.array().min(1).messages(this.joiMessages.array), + value: this.initialStateConfig?.jurisdictionAdverseActionsNotificationEmails || [], + }), + summaryReportNotificationEmails: new FormInput({ + id: 'summary-report-notification-emails', + name: 'summary-report-notification-emails', + label: computed(() => this.$t('compact.summaryReportEmails')), + labelSubtext: computed(() => this.$t('compact.summaryReportEmailsSubtext')), + placeholder: computed(() => this.$t('compact.addEmails')), + validation: Joi.array().min(1).messages(this.joiMessages.array), + value: this.initialStateConfig?.jurisdictionSummaryReportNotificationEmails || [], + }), + isPurchaseEnabled: new FormInput({ + id: 'purchase-enabled', + name: 'purchase-enabled', + label: computed(() => this.$t('compact.privilegePurchaseEnabled')), + labelSubtext: computed(() => this.$t('compact.privilegePurchaseEnabledSubtext')), + validation: Joi.boolean().required().messages(this.joiMessages.boolean), + valueOptions: [ + { value: true, name: computed(() => this.$t('common.yes')) }, + { value: false, name: computed(() => this.$t('common.no')) }, + ] as Array, + value: this.initialStateConfig?.licenseeRegistrationEnabled || false, + isDisabled: computed(() => this.isPurchaseEnabledInitialValue), + }), + submit: new FormInput({ + isSubmitInput: true, + id: 'submit-compact-settings', + }), + }); + + // Initialize the dynamic fee inputs + this.initPrivilegeFeeInputs(); + + this.watchFormInputs(); // Important if you want automated form validation + } + + initPrivilegeFeeInputs(): void { + this.initialStateConfig?.privilegeFees?.forEach((privilegeFee) => { + const licenseType = privilegeFee.licenseTypeAbbreviation; + const licenseTypeMilitary = `${licenseType}Military`; + + // Core license fee input + this.formData[licenseType] = new FormInput({ + id: `${licenseType}-fee`, + name: `${licenseType}-fee`, + label: computed(() => `${privilegeFee.name} ${this.$t('compact.fee')}`), + validation: Joi.number().required().min(0).messages(this.joiMessages.currency), + value: privilegeFee.amount, + }); + + if (this.formData[licenseType].value) { + this.formatBlur(this.formData[licenseType]); + } + + // Military license fee input + this.formData[licenseTypeMilitary] = new FormInput({ + id: `${licenseType}-fee-military`, + name: `${licenseType}-fee-military`, + label: computed(() => `${this.$t('compact.militaryAffiliated')} ${privilegeFee.name} ${this.$t('compact.fee')}`), + validation: Joi.number().min(0).allow(null, '').messages(this.joiMessages.currency), + value: privilegeFee.militaryRate, + }); + + if (this.formData[licenseTypeMilitary].value) { + this.formatBlur(this.formData[licenseTypeMilitary]); + } + + this.feeInputs.push(this.formData[licenseType]); + this.feeInputs.push(this.formData[licenseTypeMilitary]); + }); + } + + formatInput(formInput: FormInput): void { + const { value } = formInput; + const formatted = formatCurrencyInput(value); + + // Update input value + formInput.value = formatted; + } + + formatBlur(formInput: FormInput, isOptional = false): void { + const { value } = formInput; + + // Update input value + if (value !== null && value !== '') { + formInput.value = formatCurrencyBlur(value, isOptional); + } + // Validate as touched + formInput.isTouched = true; + formInput.validate(); + } + + async handleSubmit(isConfirmed = false): Promise { + this.populateOptionalMissing(); + this.validateAll({ asTouched: true }); + + if (this.isFormValid) { + const { isPurchaseEnabled } = this.formValues; + + if (isPurchaseEnabled && !this.isPurchaseEnabledInitialValue && !isConfirmed) { + this.openConfirmConfigModal(); + } else { + this.startFormLoading(); + await this.processConfigUpdate(); + this.endFormLoading(); + } + } + } + + async processConfigUpdate(): Promise { + const compact = this.compactType || ''; + const { + isJurisprudenceExamRequired, + jurisprudenceInfoLink, + opsNotificationEmails, + adverseActionNotificationEmails, + summaryReportNotificationEmails, + isPurchaseEnabled, + } = this.formValues; + const feeInputsCore = this.feeInputs.filter((feeInput) => feeInput.id.endsWith('fee')); + const payload: CompactStateConfig = { + privilegeFees: feeInputsCore.map((feeInputCore) => { + // Map indeterminate set of privilege fee inputs to their payload structure + const [ licenseType ] = feeInputCore.id.split('-'); + const militaryInput = Object.values(this.formData).find((formInput) => + (formInput as unknown as FormInput).id === `${feeInputCore.id}-military`) as FormInput | undefined; + + return { + licenseTypeAbbreviation: licenseType, + amount: Number(feeInputCore.value), + militaryRate: (militaryInput?.value === null || militaryInput?.value === '') + ? null + : Number(militaryInput?.value), + }; + }), + jurisprudenceRequirements: { + required: isJurisprudenceExamRequired, + linkToDocumentation: jurisprudenceInfoLink, + }, + jurisdictionOperationsTeamEmails: opsNotificationEmails, + jurisdictionAdverseActionsNotificationEmails: adverseActionNotificationEmails, + jurisdictionSummaryReportNotificationEmails: summaryReportNotificationEmails, + licenseeRegistrationEnabled: isPurchaseEnabled, + }; + + // Call the server API to update + await dataApi.updateCompactStateConfig(compact, this.stateAbbrev, payload).catch((err) => { + this.setError(err.message); + }); + + // Handle success + if (!this.isFormError) { + if (Object.prototype.hasOwnProperty.call(payload, 'licenseeRegistrationEnabled')) { + this.isPurchaseEnabledInitialValue = (payload.licenseeRegistrationEnabled as boolean); + } + + this.isFormSuccessful = true; + this.updateFormSubmitSuccess(this.$t('compact.saveSuccessfulState')); + } + } + + populateMissingPurchaseEnabled(): void { + if (this.formData.isPurchaseEnabled.value === '') { + this.populateFormInput(this.formData.isPurchaseEnabled, false); + } + } + + populateOptionalMissing(): void { + this.populateMissingPurchaseEnabled(); + } + + async openConfirmConfigModal(): Promise { + this.isConfirmConfigModalDisplayed = true; + await nextTick(); + document.getElementById('confirm-modal-cancel-button')?.focus(); + } + + async closeConfirmConfigModal(): Promise { + this.isConfirmConfigModalDisplayed = false; + await nextTick(); + document.getElementById(this.formData.submit.id)?.focus(); + } + + focusTrapConfirmConfigModal(event: KeyboardEvent): void { + const firstTabIndex = document.getElementById('confirm-modal-submit-button'); + const lastTabIndex = document.getElementById('confirm-modal-cancel-button'); + + if (event.shiftKey) { + // shift + tab to last input + if (document.activeElement === firstTabIndex) { + lastTabIndex?.focus(); + event.preventDefault(); + } + } else if (document.activeElement === lastTabIndex) { + // Tab to first input + firstTabIndex?.focus(); + event.preventDefault(); + } + } + + async submitConfirmConfigModal(): Promise { + this.closeConfirmConfigModal(); + await this.handleSubmit(true); + } + + async mockPopulate(): Promise { + this.feeInputs.forEach((feeInput) => { + this.populateFormInput(feeInput, 5); + }); + this.populateFormInput(this.formData.isJurisprudenceExamRequired, true); + this.populateFormInput(this.formData.jurisprudenceInfoLink, 'https://example.com'); + this.populateFormInput(this.formData.opsNotificationEmails, ['ops@example.com']); + this.populateFormInput(this.formData.adverseActionNotificationEmails, ['adverse@example.com']); + this.populateFormInput(this.formData.summaryReportNotificationEmails, ['summary@example.com']); + this.populateFormInput(this.formData.isPurchaseEnabled, true); + } + + // + // Watch + // + @Watch('compactType') fetchStateConfig() { + this.init(); + } +} + +export default toNative(StateSettingsConfig); + +// export default StateSettingsConfig; diff --git a/webroot/src/components/StateSettingsConfig/StateSettingsConfig.vue b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.vue new file mode 100644 index 000000000..b57a61877 --- /dev/null +++ b/webroot/src/components/StateSettingsConfig/StateSettingsConfig.vue @@ -0,0 +1,96 @@ + + + + + + diff --git a/webroot/src/locales/en.json b/webroot/src/locales/en.json index 4f1bc89c7..01e8de61d 100644 --- a/webroot/src/locales/en.json +++ b/webroot/src/locales/en.json @@ -10,6 +10,7 @@ "close": "Close", "skip": "Skip", "print": "Print", + "edit": "Edit", "confirm": "Confirm", "caution": "Caution", "fees": "Fees", @@ -70,6 +71,7 @@ "ethnicity": "Ethnicity", "welcome": "Welcome", "finish": "Finish", + "cannotBeUndone": "This action cannot be undone", "formValidationErrorMessage": "Please correct the errors on the form shown in red and check the box acknowledging no refunds", "daysOfTheWeek": { "monday": { @@ -369,7 +371,7 @@ "licensing": "Search licensing data", "licensingPublic": "Search health providers", "users": "Manage users", - "compactSettings": "Compact Settings", + "compactSettings": "Settings", "purchasePrivileges": "Privilege purchasing", "styleGuide": "Styleguide", "account": "Account", @@ -391,6 +393,7 @@ "inputErrors": { "required": "Required field", "email": "Must be a valid email address", + "uri": "Must be a valid complete URL", "minLength": "Must be at least {min} characters", "maxLength": "Must be less than or equal to {max} characters", "exactLength": "Must be {length} characters", @@ -417,7 +420,11 @@ "fileWrongType": "Wrong file type", "lastNameRequired": "Required when first name is provided", "ssnFormat": "Must be in format ###-##-####", - "enterValidCreditCard": "Must be a valid credit card number" + "enterValidCreditCard": "Must be a valid credit card number", + "numberType": "Must be a number", + "currencyType": "Must be a valid currency amount", + "minNumber": "Must be greater than or equal to {min}", + "maxNumber": "Must be less than or equal to {max}" }, "serverErrors": { "networkError": "Network error", @@ -460,10 +467,40 @@ } ], "compact": { - "settingsTitle": "Compact settings", + "settingsTitle": "Settings", + "configuration": "Configuration", "paymentProcessorCredentials": "Authorize.net credentials", "paymentProcessorAccountId": "API login ID", - "paymentProcessorAccountKey": "Transaction key" + "paymentProcessorAccountKey": "Transaction key", + "configLoadingErrorCompact": "There was an error loading the compact configuration", + "configLoadingErrorState": "There was an error loading the state configuration", + "privilegeFees": "Privilege fees", + "compactFee": "Compact fee", + "privilegeTransactionFee": "Privilege transaction fee", + "fee": "fee", + "militaryAffiliated": "Military-affiliated", + "jurisprudence": "Jurisprudence", + "jurisprudenceExamRequired": "Exam required?", + "jurisprudenceInfoLink": "Jurisprudence information link", + "jurisprudenceInfoLinkSubtext": "Compact privilege seekers will be able to follow this link for additional information about your state's jurisprudence requirements", + "notifications": "Notifications", + "addEmails": "Add emails here", + "enterToAdd": "Hit Enter to add", + "opsNotificationEmails": "Operations notifications recipients", + "opsNotificationEmailsSubtext": "Enter all recipients who should be notified of support requests (e.g. invalid data that a jurisdiction has uploaded, or any other issues that may arise).", + "adverseActionsNotificationEmails": "Encumbrance notification recipients", + "adverseActionsNotificationEmailsSubtext": "Enter all recipients who should be notified of encumbrances (e.g. licensees that have had their licenses revoked or suspended).", + "summaryReportEmails": "Summary report recipients", + "summaryReportEmailsSubtext": "Enter all recipients who will receive periodic summary reports.", + "licenseRegistrationTitle": "Live status", + "licenseRegistrationEnabled": "Is license registration enabled?", + "licenseRegistrationEnabledSubtext": "This enables live status for purchasing privileges, and this action is irreversible.", + "privilegePurchaseEnabled": "State is live for compact privilege purchase", + "privilegePurchaseEnabledSubtext": "This enables live status for purchasing privileges, and this action is irreversible.", + "confirmSaveCompactTitle": "Are you sure you want to mark this state as live?", + "confirmSaveCompactYes": "Yes, mark as live", + "saveSuccessfulCompact": "Compact changes saved successfully", + "saveSuccessfulState": "State changes saved successfully" }, "stateUpload": { "formTitle": "Compact data upload", diff --git a/webroot/src/locales/es.json b/webroot/src/locales/es.json index 9b9426fc8..40df1159c 100644 --- a/webroot/src/locales/es.json +++ b/webroot/src/locales/es.json @@ -12,6 +12,7 @@ "close": "Cerrar", "skip": "Saltear", "print": "Imprimir", + "edit": "Editar", "fees": "Tarifas", "choose": "Elige", "chooseOne": "Elige uno", @@ -68,8 +69,9 @@ "language": "Idioma", "gender": "Género", "race": "Raza", - "finish": "Termina", "ethnicity": "Origen étnico", + "finish": "Termina", + "cannotBeUndone": "Esta acción no se puede deshacer", "daysOfTheWeek": { "monday": { "full": "Lunes", @@ -368,7 +370,7 @@ "licensing": "Licencia", "licensingPublic": "Buscar proveedores de salud", "users": "Usuarios", - "compactSettings": "Configuraciones compactas", + "compactSettings": "Ajustes", "purchasePrivileges": "Compras con privilegios", "styleGuide": "Guía de estilo", "account": "Cuenta", @@ -390,6 +392,7 @@ "inputErrors": { "required": "Campo requerido", "email": "Debe ser una dirección de correo electrónico válida", + "uri": "Debe ser una URL completa válida", "minLength": "Debe tener al menos {min} caracteres", "maxLength": "Debe tener menos o igual a {max} caracteres", "exactLength": "Debe tener {length} caracteres", @@ -416,7 +419,11 @@ "fileWrongType": "Tipo de archivo incorrecto", "lastNameRequired": "Obligatorio cuando se proporciona el nombre", "ssnFormat": "Debe estar en formato ###-##-####", - "enterValidCreditCard": "Debe ser un número de tarjeta de crédito válido." + "enterValidCreditCard": "Debe ser un número de tarjeta de crédito válido.", + "numberType": "Debe ser un número", + "currencyType": "Debe ser un monto en moneda válido", + "minNumber": "Debe ser mayor o igual a {min}", + "maxNumber": "Debe ser menor o igual a {max}" }, "serverErrors": { "networkError": "Error de red", @@ -459,10 +466,40 @@ } ], "compact": { - "settingsTitle": "Configuraciones compactas", + "settingsTitle": "Ajustes", + "configuration": "Configuración", "paymentProcessorCredentials": "Authorize.net cartas credenciales", "paymentProcessorAccountId": "ID de cuenta API", - "paymentProcessorAccountKey": "Clave de transacción" + "paymentProcessorAccountKey": "Clave de transacción", + "configLoadingErrorCompact": "Se produjo un error al cargar la configuración compacta.", + "configLoadingErrorState": "Se produjo un error al cargar la configuración del estado.", + "privilegeFees": "Tarifas de privilegio", + "compactFee": "Tarifa compacta", + "privilegeTransactionFee": "Tarifa de transacción privilegiada", + "fee": "tarifa", + "militaryAffiliated": "Afiliado a los militares", + "jurisprudence": "Jurisprudencia", + "jurisprudenceExamRequired": "¿Es necesario un examen?", + "jurisprudenceInfoLink": "Enlace de información sobre jurisprudencia", + "jurisprudenceInfoLinkSubtext": "Los solicitantes de privilegios compactos podrán seguir este enlace para obtener información adicional sobre los requisitos de jurisprudencia de su estado.", + "notifications": "Notificaciones", + "addEmails": "Añade correos electrónicos aquí", + "enterToAdd": "Presione Enter para agregar", + "opsNotificationEmails": "Destinatarios de notificaciones de operaciones", + "opsNotificationEmailsSubtext": "Ingrese todos los destinatarios a quienes se les debe notificar sobre solicitudes de soporte (por ejemplo, datos no válidos que una jurisdicción ha cargado o cualquier otro problema que pueda surgir).", + "adverseActionsNotificationEmails": "Destinatarios de notificación de gravamen", + "adverseActionsNotificationEmailsSubtext": "Ingrese todos los destinatarios a quienes se les debe notificar sobre gravámenes (por ejemplo, licenciatarios a quienes se les ha revocado o suspendido su licencia).", + "summaryReportEmails": "Destinatarios del informe resumido", + "summaryReportEmailsSubtext": "Ingrese todos los destinatarios que recibirán informes resumidos periódicos.", + "licenseRegistrationTitle": "Estado en vivo", + "licenseRegistrationEnabled": "¿Está habilitado el registro de licencia?", + "licenseRegistrationEnabledSubtext": "Esto habilita el estado en vivo para los privilegios de compra, y esta acción es irreversible.", + "privilegePurchaseEnabled": "El estado está en línea para la compra de privilegios compactos", + "privilegePurchaseEnabledSubtext": "Esto habilita el estado en vivo para los privilegios de compra, y esta acción es irreversible.", + "confirmSaveCompactTitle": "¿Estás seguro de que deseas marcar este estado como activo?", + "confirmSaveCompactYes": "Sí, marcar como en vivo", + "saveSuccessfulCompact": "Los cambios compactos se guardaron correctamente", + "saveSuccessfulState": "Los cambios de estado se guardaron correctamente" }, "stateUpload": { "formTitle": "Carga de datos compacta", diff --git a/webroot/src/models/Compact/Compact.model.ts b/webroot/src/models/Compact/Compact.model.ts index 8ff975238..63a26972d 100644 --- a/webroot/src/models/Compact/Compact.model.ts +++ b/webroot/src/models/Compact/Compact.model.ts @@ -16,7 +16,7 @@ import { CompactFeeConfig } from '@models/CompactFeeConfig/CompactFeeConfig.mode export enum CompactType { // Temp server definition until server returns via endpoint ASLP = 'aslp', OT = 'octp', - COUNSILING = 'coun', + COUNSELING = 'coun', } export interface PaymentProcessorConfig { @@ -25,6 +25,51 @@ export interface PaymentProcessorConfig { processor: string; } +export enum FeeType { + FLAT_RATE = 'FLAT_RATE', + FLAT_FEE_PER_PRIVILEGE = 'FLAT_FEE_PER_PRIVILEGE', +} + +export interface CompactConfig { + compactAbbr?: string, + compactName?: string, + licenseeRegistrationEnabled: boolean, + compactCommissionFee: { + feeType: FeeType, + feeAmount: number, + }, + compactOperationsTeamEmails: Array, + compactAdverseActionsNotificationEmails: Array, + compactSummaryReportNotificationEmails: Array, + transactionFeeConfiguration: { + licenseeCharges: { + active: boolean, + chargeType: FeeType, + chargeAmount: number, + }, + }, +} + +export interface CompactStateConfig { + compact?: string, + jurisdictionName?: string, + postalAbbreviation?: string, + licenseeRegistrationEnabled: boolean, + privilegeFees: Array<{ + licenseTypeAbbreviation: string, + amount: number, + militaryRate: number | null, // Specific mix of number & null required by server + name?: string, + }> + jurisprudenceRequirements: { + required: boolean, + linkToDocumentation: string | null, + }, + jurisdictionOperationsTeamEmails: Array, + jurisdictionAdverseActionsNotificationEmails: Array, + jurisdictionSummaryReportNotificationEmails: Array, +} + export interface InterfaceCompactCreate { id?: string | null; type?: CompactType | null; diff --git a/webroot/src/models/FormInput/FormInput.model.spec.ts b/webroot/src/models/FormInput/FormInput.model.spec.ts index 2af5ae32c..16dcf5ade 100644 --- a/webroot/src/models/FormInput/FormInput.model.spec.ts +++ b/webroot/src/models/FormInput/FormInput.model.spec.ts @@ -22,6 +22,7 @@ describe('FormInput model', () => { expect(formInput.id).to.equal(''); expect(formInput.name).to.equal(''); expect(formInput.label).to.equal(''); + expect(formInput.labelSubtext).to.equal(''); expect(formInput.shouldHideLabel).to.equal(false); expect(formInput.isLabelHTML).to.equal(false); expect(formInput.placeholder).to.equal(''); @@ -83,6 +84,7 @@ describe('FormInput model', () => { id: 'test', name: 'test', label: 'test', + labelSubtext: 'test', shouldHideLabel: true, isLabelHTML: true, placeholder: 'test', @@ -123,6 +125,7 @@ describe('FormInput model', () => { expect(formInput.id).to.equal(values.id); expect(formInput.name).to.equal(values.name); expect(formInput.label).to.equal(values.label); + expect(formInput.labelSubtext).to.equal(values.labelSubtext); expect(formInput.shouldHideLabel).to.equal(values.shouldHideLabel); expect(formInput.isLabelHTML).to.equal(values.isLabelHTML); expect(formInput.placeholder).to.equal(values.placeholder); diff --git a/webroot/src/models/FormInput/FormInput.model.ts b/webroot/src/models/FormInput/FormInput.model.ts index d20c40bbf..77856857a 100644 --- a/webroot/src/models/FormInput/FormInput.model.ts +++ b/webroot/src/models/FormInput/FormInput.model.ts @@ -15,10 +15,11 @@ export interface InterfaceFormInput { id?: string; name?: string; label?: string | ComputedRef; + labelSubtext?: string | ComputedRef; shouldHideLabel?: boolean; isLabelHTML?: boolean; placeholder?: string | ComputedRef; - value?: string | number | boolean | null | Array; + value?: string | number | boolean | null | Array | Array; valueOptions?: Array<{ value: any; name: string | ComputedRef; }>; autocomplete?: string; fileConfig?: { @@ -54,6 +55,7 @@ export class FormInput implements InterfaceFormInput { public id = ''; public name = ''; public label = ''; + public labelSubtext = ''; public shouldHideLabel = false; public isLabelHTML = false; public placeholder = ''; diff --git a/webroot/src/models/_formatters/currency.ts b/webroot/src/models/_formatters/currency.ts new file mode 100644 index 000000000..0bc30e281 --- /dev/null +++ b/webroot/src/models/_formatters/currency.ts @@ -0,0 +1,77 @@ +// +// currency.ts +// InspiringApps modules +// +// Created by InspiringApps on 5/15/25. +// + +/** + * Currency formatter for active input. + * Will less aggressively format, since the user is actively interacting. + * @param {string | number} value The active input value. + * @return {string} + */ +const formatCurrencyInput = (value: string | number = ''): string => { + let [ dollars, cents ] = value.toString().split(/\.(.*)/s); + const hasDecimal = cents !== undefined; + let formatted = ''; + + // Get raw dollar & cent values + dollars = dollars.replace(/\D/g, ''); + cents = (hasDecimal) ? cents.replace(/\D/g, '') : ''; + + // Prevent cents from having too many decimal places + if (cents.length > 2) { + cents = cents.slice(0, 2); + } + + // Format with more forgiving typing-in-progress allowances + if (dollars && hasDecimal) { + formatted = `${dollars}.${cents}`; + } else if (dollars) { + formatted = `${dollars}`; + } else if (cents) { + formatted = `0.${cents}`; + } + + return formatted; +}; + +/** + * Currency formatter for completed input. + * Will more aggressively format, since the user is done interacting. + * @param {string | number} value The value held by the blurred input. + * @param {boolean} [isOptional=false] TRUE if the input value is optional. + * @return {string} + */ +const formatCurrencyBlur = (value: string | number = '', isOptional = false): string => { + let [ dollars, cents ] = (value || '').toString().split(/\.(.*)/s); + let formatted = ''; + + if (!value && isOptional) { + // Autofill if optional input is blank + dollars = '0'; + } else if (cents?.length === 1) { + // Add trailing digit to cents if needed + cents += '0'; + } else if (cents?.length > 2) { + // Prevent cents from having too many decimal places + cents = cents.slice(0, 2); + } + + // Format with more strict done-typing cleanups + if (dollars && cents) { + formatted = `${dollars}.${cents}`; + } else if (dollars) { + formatted = `${dollars}`; + } else if (cents) { + formatted = `0.${cents}`; + } + + return formatted; +}; + +export { + formatCurrencyInput, + formatCurrencyBlur, +}; diff --git a/webroot/src/models/_formatters/date.ts b/webroot/src/models/_formatters/date.ts index 2393f474a..a82a0954a 100644 --- a/webroot/src/models/_formatters/date.ts +++ b/webroot/src/models/_formatters/date.ts @@ -1,5 +1,5 @@ // -// _helpers.ts +// date.ts // InspiringApps modules // // Created by InspiringApps on 4/12/20. diff --git a/webroot/src/models/_formatters/formatters.spec.ts b/webroot/src/models/_formatters/formatters.spec.ts index 181823dd0..bdd5366dd 100644 --- a/webroot/src/models/_formatters/formatters.spec.ts +++ b/webroot/src/models/_formatters/formatters.spec.ts @@ -19,6 +19,7 @@ import { dateDiff } from '@models/_formatters/date'; import { singleDelimeterPhoneFormatter, formatPhoneNumber, stripPhoneNumber } from '@models/_formatters/phone'; +import { formatCurrencyInput, formatCurrencyBlur } from '@models/_formatters/currency'; import moment from 'moment'; describe('Date formatters', () => { @@ -84,3 +85,70 @@ describe('Phone formatters', () => { expect(formatted).to.equal('12'); }); }); +describe('Currency formatters', () => { + it('should return empty when value param is empty (input)', () => { + const formatted = formatCurrencyInput(); + + expect(formatted).to.equal(''); + }); + it('should return correctly formatted when value param is only dollars (input)', () => { + const formatted = formatCurrencyInput('10'); + + expect(formatted).to.equal('10'); + }); + it('should return correctly formatted when value param is only dollars with trailing decimal point (input)', () => { + const formatted = formatCurrencyInput('10.'); + + expect(formatted).to.equal('10.'); + }); + it('should return correctly formatted when value param has partial cents (input)', () => { + const formatted = formatCurrencyInput('1.1'); + + expect(formatted).to.equal('1.1'); + }); + it('should return correctly formatted when value param has too much precision for cents (input)', () => { + const formatted = formatCurrencyInput('1.111'); + + expect(formatted).to.equal('1.11'); + }); + it('should return correctly formatted when value param cents only (input)', () => { + const formatted = formatCurrencyInput('.111'); + + expect(formatted).to.equal('0.11'); + }); + it('should return empty for blur when value param is empty (blur)', () => { + const formatted = formatCurrencyBlur(); + + expect(formatted).to.equal(''); + }); + it('should return return correctly formatted when value param is empty and discretely not optional (blur)', () => { + const formatted = formatCurrencyBlur('', false); + + expect(formatted).to.equal(''); + }); + it('should return return correctly formatted when value param is empty and optional (blur)', () => { + const formatted = formatCurrencyBlur('', true); + + expect(formatted).to.equal('0'); + }); + it('should return return correctly formatted when value param only has 1 decimal place for cents (blur)', () => { + const formatted = formatCurrencyBlur('1.1'); + + expect(formatted).to.equal('1.10'); + }); + it('should return correctly formatted when value param is only dollars (blur)', () => { + const formatted = formatCurrencyBlur('1'); + + expect(formatted).to.equal('1'); + }); + it('should return correctly formatted when value param is only dollars with trailing decimal point (blur)', () => { + const formatted = formatCurrencyBlur('1.'); + + expect(formatted).to.equal('1'); + }); + it('should return correctly formatted when value param cents only (blur)', () => { + const formatted = formatCurrencyBlur('.111'); + + expect(formatted).to.equal('0.11'); + }); +}); diff --git a/webroot/src/network/data.api.ts b/webroot/src/network/data.api.ts index 21b352138..f55a49911 100644 --- a/webroot/src/network/data.api.ts +++ b/webroot/src/network/data.api.ts @@ -9,7 +9,7 @@ import { stateDataApi } from '@network/stateApi/data.api'; import { licenseDataApi } from '@network/licenseApi/data.api'; import { userDataApi } from '@network/userApi/data.api'; import { exampleDataApi } from '@network/exampleApi/data.api'; -import { PaymentProcessorConfig } from '@models/Compact/Compact.model'; +import { PaymentProcessorConfig, CompactConfig, CompactStateConfig } from '@models/Compact/Compact.model'; export class DataApi { /** @@ -48,7 +48,7 @@ export class DataApi { /** * POST Compact payment processer config. - * @param {string} compact The compact string ID (aslp, ot, counseling). + * @param {string} compact The compact string ID (aslp, octp, coun). * @param {PaymentProcessorConfig} config The payment processer config data. * @return {Promise} The server response. */ @@ -56,6 +56,46 @@ export class DataApi { return stateDataApi.updatePaymentProcessorConfig(compact, config); } + /** + * GET Compact config. + * @param {string} compact The compact string ID (aslp, octp, coun). + * @return {Promise} The server response. + */ + public getCompactConfig(compact: string) { + return stateDataApi.getCompactConfig(compact); + } + + /** + * PUT Compact config. + * @param {string} compact The compact string ID (aslp, octp, coun). + * @param {CompactConfig} config The compact config data. + * @return {Promise} The server response. + */ + public updateCompactConfig(compact: string, config: CompactConfig) { + return stateDataApi.updateCompactConfig(compact, config); + } + + /** + * GET State config. + * @param {string} compact The compact string ID (aslp, octp, coun). + * @param {string} state The 2-character state abbreviation. + * @return {Promise} The server response. + */ + public getCompactStateConfig(compact: string, state: string) { + return stateDataApi.getCompactStateConfig(compact, state); + } + + /** + * PUT State config. + * @param {string} compact The compact string ID (aslp, octp, coun). + * @param {string} state The 2-character state abbreviation. + * @param {CompactStateConfig} config The compact config data. + * @return {Promise} The server response. + */ + public updateCompactStateConfig(compact: string, state: string, config: CompactStateConfig) { + return stateDataApi.updateCompactStateConfig(compact, state, config); + } + // ======================================================================== // LICENSE API // ======================================================================== diff --git a/webroot/src/network/mocks/mock.data.api.ts b/webroot/src/network/mocks/mock.data.api.ts index c050ee096..40b5747d8 100644 --- a/webroot/src/network/mocks/mock.data.api.ts +++ b/webroot/src/network/mocks/mock.data.api.ts @@ -22,7 +22,9 @@ import { pets, privilegePurchaseOptionsResponse, attestation, - compactStates + compactStates, + compactConfig, + stateConfig } from '@network/mocks/mock.data'; let mockStore: any = null; @@ -72,7 +74,7 @@ export class DataApi { })); } - // + // Post compact payment processor config public updatePaymentProcessorConfig(compact: string, config: object) { return wait(500).then(() => ({ message: 'success', @@ -81,6 +83,42 @@ export class DataApi { })); } + // Get compact config + public getCompactConfig(compact: string) { + return wait(500).then(() => ({ + ...compactConfig, + compact, + })); + } + + // Put compact config + public updateCompactConfig(compact: string, config: object) { + return wait(500).then(() => ({ + message: 'success', + compact, + config, + })); + } + + // Get state config + public getCompactStateConfig(compact: string, state: string) { + return wait(500).then(() => ({ + ...stateConfig, + compact, + state + })); + } + + // Put state config + public updateCompactStateConfig(compact: string, state: string, config: object) { + return wait(500).then(() => ({ + message: 'success', + compact, + state, + config, + })); + } + // ======================================================================== // LICENSE API // ======================================================================== diff --git a/webroot/src/network/mocks/mock.data.ts b/webroot/src/network/mocks/mock.data.ts index cfcfd1b0c..642b800d6 100644 --- a/webroot/src/network/mocks/mock.data.ts +++ b/webroot/src/network/mocks/mock.data.ts @@ -1769,6 +1769,64 @@ export const compactStates = [ }, ]; +export const compactConfig = { + compactAbbr: 'otcp', + compactName: 'Occupational Therapy', + compactCommissionFee: { + feeType: 'FLAT_RATE', + feeAmount: 10, + }, + licenseeRegistrationEnabled: false, + compactOperationsTeamEmails: [ + 'ops@example.com', + ], + compactAdverseActionsNotificationEmails: [ + 'adverse@example.com', + ], + compactSummaryReportNotificationEmails: [ + 'summary@example.com', + ], + transactionFeeConfiguration: { + licenseeCharges: { + active: true, + chargeType: 'FLAT_FEE_PER_PRIVILEGE', + chargeAmount: 5, + }, + }, +}; + +export const stateConfig = { + compact: 'otcp', + jurisdictionName: 'Kentucky', + postalAbbreviation: 'ky', + licenseeRegistrationEnabled: false, + privilegeFees: [ + { + licenseTypeAbbreviation: 'ot', + amount: 30, + militaryRate: null, + }, + { + licenseTypeAbbreviation: 'ota', + amount: 30, + militaryRate: 25, + }, + ], + jurisprudenceRequirements: { + required: true, + linkToDocumentation: 'https://example.com', + }, + jurisdictionOperationsTeamEmails: [ + 'ops@example.com', + ], + jurisdictionAdverseActionsNotificationEmails: [ + 'adverse@example.com', + ], + jurisdictionSummaryReportNotificationEmails: [ + 'summary@example.com', + ], +}; + export const pets = [ { id: 1, diff --git a/webroot/src/network/stateApi/data.api.ts b/webroot/src/network/stateApi/data.api.ts index 18b9610ef..b2a3322d2 100644 --- a/webroot/src/network/stateApi/data.api.ts +++ b/webroot/src/network/stateApi/data.api.ts @@ -13,7 +13,7 @@ import { responseError } from '@network/stateApi/interceptors'; import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; -import { PaymentProcessorConfig } from '@models/Compact/Compact.model'; +import { PaymentProcessorConfig, CompactConfig, CompactStateConfig } from '@models/Compact/Compact.model'; export interface DataApiInterface { api: AxiosInstance; @@ -116,6 +116,46 @@ export class StateDataApi implements DataApiInterface { public updatePaymentProcessorConfig(compact: string, config: PaymentProcessorConfig) { return this.api.post(`v1/compacts/${compact}/credentials/payment-processor`, config); } + + /** + * GET Compact config. + * @param {string} compact The compact string ID (aslp, octp, coun). + * @return {Promise} The server response. + */ + public getCompactConfig(compact: string) { + return this.api.get(`v1/compacts/${compact}`); + } + + /** + * PUT Compact config. + * @param {string} compact The compact string ID (aslp, octp, coun). + * @param {CompactConfig} config The compact config data. + * @return {Promise} The server response. + */ + public updateCompactConfig(compact: string, config: CompactConfig) { + return this.api.put(`v1/compacts/${compact}`, config); + } + + /** + * GET State config. + * @param {string} compact The compact string ID (aslp, octp, coun). + * @param {string} state The 2-character state abbreviation. + * @return {Promise} The server response. + */ + public getCompactStateConfig(compact: string, state: string) { + return this.api.get(`v1/compacts/${compact}/jurisdictions/${state.toLowerCase()}`); + } + + /** + * PUT State config. + * @param {string} compact The compact string ID (aslp, octp, coun). + * @param {string} state The 2-character state abbreviation. + * @param {CompactStateConfig} config The compact config data. + * @return {Promise} The server response. + */ + public updateCompactStateConfig(compact: string, state: string, config: CompactStateConfig) { + return this.api.put(`v1/compacts/${compact}/jurisdictions/${state.toLowerCase()}`, config); + } } export const stateDataApi = new StateDataApi(); diff --git a/webroot/src/pages/CompactSettings/CompactSettings.less b/webroot/src/pages/CompactSettings/CompactSettings.less index 7c8452b36..9cc813273 100644 --- a/webroot/src/pages/CompactSettings/CompactSettings.less +++ b/webroot/src/pages/CompactSettings/CompactSettings.less @@ -10,8 +10,48 @@ max-width: 60rem; min-height: ~"calc(100vh - @{appHeaderHeight} - @{appFooterHeight})"; margin: 0 auto; + padding: 1.6rem; .compact-settings-title { margin-bottom: 3.2rem; } + + .section { + margin-bottom: 3.2rem; + + @media @desktopWidth { + min-width: 50rem; + } + } + + .state-list { + display: flex; + flex-direction: column; + margin-bottom: 2.4rem; + border-radius: 8px; + background-color: @white; + + .state-row { + display: flex; + min-height: 6.4rem; + padding: 1.6rem; + + &.header-row { + font-weight: @fontWeightBold; + } + + &:not(.header-row) { + border-top: 1px solid @veryLightGrey; + } + + .state-cell { + display: flex; + align-items: center; + + &.actions { + margin-left: auto; + } + } + } + } } diff --git a/webroot/src/pages/CompactSettings/CompactSettings.ts b/webroot/src/pages/CompactSettings/CompactSettings.ts index 40ff6f456..9d942b87d 100644 --- a/webroot/src/pages/CompactSettings/CompactSettings.ts +++ b/webroot/src/pages/CompactSettings/CompactSettings.ts @@ -5,16 +5,138 @@ // Created by InspiringApps on 12/5/2024. // -import { Component, Vue } from 'vue-facing-decorator'; +import { Component, Vue, Watch } from 'vue-facing-decorator'; +import { AuthTypes } from '@/app.config'; import Section from '@components/Section/Section.vue'; import PaymentProcessorConfig from '@components/PaymentProcessorConfig/PaymentProcessorConfig.vue'; +import CompactSettingsConfig from '@components/CompactSettingsConfig/CompactSettingsConfig.vue'; +import { Compact } from '@models/Compact/Compact.model'; +import { CompactPermission, StatePermission } from '@models/StaffUser/StaffUser.model'; @Component({ name: 'CompactSettings', components: { Section, PaymentProcessorConfig, + CompactSettingsConfig, } }) export default class CompactSettings extends Vue { + // + // Lifecycle + // + created(): void { + this.init(); + } + + // + // Computed + // + get globalStore() { + return this.$store.state; + } + + get authType(): string { + return this.globalStore.authType; + } + + get userStore() { + return this.$store.state.user; + } + + get currentCompact(): Compact | null { + return this.userStore.currentCompact; + } + + get user() { + return this.userStore.model; + } + + get isLoggedIn(): boolean { + return this.userStore.isLoggedIn; + } + + get isLoggedInAsStaff(): boolean { + return this.authType === AuthTypes.STAFF; + } + + get staffPermission(): CompactPermission | null { + const currentPermissions = this.user?.permissions; + const compactPermission = currentPermissions?.find((currentPermission) => + currentPermission.compact.type === this.currentCompact?.type) || null; + + return compactPermission; + } + + get isCompactAdmin(): boolean { + return this.isLoggedInAsStaff && Boolean(this.staffPermission?.isAdmin); + } + + get statePermissionsAdmin(): Array { + return this.staffPermission?.states?.filter((statePermission) => statePermission.isAdmin) || []; + } + + get isStateAdminAny(): boolean { + return this.isLoggedInAsStaff && this.statePermissionsAdmin.length > 0; + } + + get isStateAdminMultiple(): boolean { + return this.isLoggedInAsStaff && this.statePermissionsAdmin.length > 1; + } + + get isStateAdminExactlyOne(): boolean { + return this.isLoggedInAsStaff && this.statePermissionsAdmin.length === 1; + } + + get shouldShowStateList(): boolean { + return (this.isCompactAdmin && this.isStateAdminAny) || this.isStateAdminMultiple; + } + + // + // Methods + // + init(): void { + this.permissionRedirectCheck(); + } + + permissionRedirectCheck(): void { + if (this.currentCompact && this.user && !this.isCompactAdmin) { + if (!this.isStateAdminAny) { + // Not compact or state admin, so redirect to home page + this.$router.replace({ name: 'Home' }); + } else if (this.isStateAdminExactlyOne) { + // Not compact admin and state admin for only 1 state, so redirect to state config page + this.routeToStateConfig(this.statePermissionsAdmin[0]?.state?.abbrev || '', true); + } + } + } + + routeToStateConfig(abbrev: string, isRouteReplace = false): void { + if (this.currentCompact?.type) { + const routeConfig = { + name: 'StateSettings', + params: { + compact: this.currentCompact?.type, + state: abbrev, + }, + }; + + if (isRouteReplace) { + this.$router.replace(routeConfig); + } else { + this.$router.push(routeConfig); + } + } + } + + // + // Watch + // + @Watch('currentCompact') currentCompactUpdate() { + this.permissionRedirectCheck(); + } + + @Watch('user') userUpdate() { + this.permissionRedirectCheck(); + } } diff --git a/webroot/src/pages/CompactSettings/CompactSettings.vue b/webroot/src/pages/CompactSettings/CompactSettings.vue index 6ada7b31d..792effbf1 100644 --- a/webroot/src/pages/CompactSettings/CompactSettings.vue +++ b/webroot/src/pages/CompactSettings/CompactSettings.vue @@ -7,8 +7,32 @@ diff --git a/webroot/src/pages/StateSettings/StateSettings.less b/webroot/src/pages/StateSettings/StateSettings.less new file mode 100644 index 000000000..c09fd070e --- /dev/null +++ b/webroot/src/pages/StateSettings/StateSettings.less @@ -0,0 +1,29 @@ +// +// StateSettings.less +// CompactConnect +// +// Created by InspiringApps on 5/20/2025. +// + +.state-settings-container { + justify-content: center; + max-width: 60rem; + margin: 0 auto; + padding: 1.6rem; + + .state-settings-title { + margin-bottom: 3.2rem; + } + + .section { + margin-bottom: 3.2rem; + + @media @desktopWidth { + min-width: 50rem; + } + } + + .state-config { + margin-bottom: 2.4rem; + } +} diff --git a/webroot/src/pages/StateSettings/StateSettings.spec.ts b/webroot/src/pages/StateSettings/StateSettings.spec.ts new file mode 100644 index 000000000..ed98381d9 --- /dev/null +++ b/webroot/src/pages/StateSettings/StateSettings.spec.ts @@ -0,0 +1,19 @@ +// +// StateSettings.spec.ts +// CompactConnect +// +// Created by InspiringApps on 5/20/2025. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import StateSettings from '@pages/StateSettings/StateSettings.vue'; + +describe('StateSettings page', async () => { + it('should mount the page component', async () => { + const wrapper = await mountShallow(StateSettings); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(StateSettings).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/pages/StateSettings/StateSettings.ts b/webroot/src/pages/StateSettings/StateSettings.ts new file mode 100644 index 000000000..e8e92238d --- /dev/null +++ b/webroot/src/pages/StateSettings/StateSettings.ts @@ -0,0 +1,118 @@ +// +// StateSettings.ts +// CompactConnect +// +// Created by InspiringApps on 5/20/2025. +// + +import { Component, Vue, Watch } from 'vue-facing-decorator'; +import { AuthTypes } from '@/app.config'; +import Section from '@components/Section/Section.vue'; +import StateSettingsConfig from '@components/StateSettingsConfig/StateSettingsConfig.vue'; +import InputButton from '@components/Forms/InputButton/InputButton.vue'; +import { Compact } from '@models/Compact/Compact.model'; +import { CompactPermission, StatePermission } from '@models/StaffUser/StaffUser.model'; + +@Component({ + name: 'StateSettings', + components: { + Section, + StateSettingsConfig, + InputButton, + }, +}) +export default class StateSettings extends Vue { + // + // Lifecycle + // + created(): void { + this.init(); + } + + // + // Computed + // + get stateAbbrev(): string { + return (this.$route.params?.state as string) || ''; + } + + get globalStore() { + return this.$store.state; + } + + get authType(): string { + return this.globalStore.authType; + } + + get userStore() { + return this.$store.state.user; + } + + get currentCompact(): Compact | null { + return this.userStore.currentCompact; + } + + get user() { + return this.userStore.model; + } + + get isLoggedIn(): boolean { + return this.userStore.isLoggedIn; + } + + get isLoggedInAsStaff(): boolean { + return this.authType === AuthTypes.STAFF; + } + + get staffPermission(): CompactPermission | null { + const currentPermissions = this.user?.permissions; + const compactPermission = currentPermissions?.find((currentPermission) => + currentPermission.compact.type === this.currentCompact?.type) || null; + + return compactPermission; + } + + get statePermission(): StatePermission | null { + return this.staffPermission?.states?.find((permission) => permission.state.abbrev === this.stateAbbrev) || null; + } + + get isStateAdmin(): boolean { + return Boolean(this.isLoggedInAsStaff && this.statePermission?.isAdmin); + } + + get pageTitle(): string { + const stateName = this.statePermission?.state?.name() || ''; + const compactName = this.currentCompact?.abbrev() || ''; + + return `${stateName}-${compactName} ${(this.$t('compact.configuration') || '').toLowerCase()}`; + } + + // + // Methods + // + init(): void { + this.permissionRedirectCheck(); + } + + permissionRedirectCheck(): void { + if (this.currentCompact && this.user && !this.isStateAdmin) { + // User is not an admin for the requested state, so redirect to home page + this.$router.replace({ name: 'Home' }); + } + } + + goBack() { + this.$router.go(-1); + } + + // + // Watch + // + @Watch('currentCompact') currentCompactUpdate() { + this.permissionRedirectCheck(); + } + + @Watch('user') userUpdate() { + this.permissionRedirectCheck(); + } +} diff --git a/webroot/src/pages/StateSettings/StateSettings.vue b/webroot/src/pages/StateSettings/StateSettings.vue new file mode 100644 index 000000000..ef306b59a --- /dev/null +++ b/webroot/src/pages/StateSettings/StateSettings.vue @@ -0,0 +1,28 @@ + + + + + + diff --git a/webroot/src/router/routes.ts b/webroot/src/router/routes.ts index 8710980f1..d298a7c1b 100644 --- a/webroot/src/router/routes.ts +++ b/webroot/src/router/routes.ts @@ -89,7 +89,13 @@ const routes: Array = [ { path: '/:compact/Settings', name: 'CompactSettings', - component: () => import(/* webpackChunkName: "licensing" */ '@pages/CompactSettings/CompactSettings.vue'), + component: () => import(/* webpackChunkName: "upload" */ '@pages/CompactSettings/CompactSettings.vue'), + meta: { requiresAuth: true, staffAccess: true }, + }, + { + path: '/:compact/Settings/:state', + name: 'StateSettings', + component: () => import(/* webpackChunkName: "upload" */ '@pages/StateSettings/StateSettings.vue'), meta: { requiresAuth: true, staffAccess: true }, }, { diff --git a/webroot/src/styles.common/_inputs.less b/webroot/src/styles.common/_inputs.less index 6a808b091..b1a9b2d26 100644 --- a/webroot/src/styles.common/_inputs.less +++ b/webroot/src/styles.common/_inputs.less @@ -21,6 +21,7 @@ label, .input-label { display: flex; + flex-wrap: wrap; align-items: center; margin-bottom: 0.6rem; color: @fontColor; @@ -28,12 +29,20 @@ font-size: 1.6rem; .icon { + flex-basis: 2%; padding-right: 0.6rem; } + + .input-label-subtext { + flex-basis: 100%; + color: darken(@darkGrey, 10%); + font-weight: @fontWeight; + font-size: 1.2rem; + } } .required-indicator { - padding-left: 4px; + padding-left: 2px; } input, @@ -54,7 +63,8 @@ justify-content: center; } - &:placeholder { + &::placeholder { + padding-left: 0.4rem; font-family: @fontFamily; }