From 1b2c830ecfc54f0cc6751664a54b390ca2ff277d Mon Sep 17 00:00:00 2001 From: zgong-gov <123983557+zgong-gov@users.noreply.github.com> Date: Thu, 25 Jul 2024 10:56:49 -0700 Subject: [PATCH] ORV2-2297: Special Authorization Page with LOA View and Management (#1512) Co-authored-by: gchauhan-aot Co-authored-by: praju-aot Co-authored-by: Praveen Raju <80779423+praju-aot@users.noreply.github.com> --- .../common/apiManager/httpRequestHandler.ts | 32 ++ .../components/dashboard/Dashboard.scss | 36 +- .../dashboard/DashboardTabPanels.scss | 3 + .../dashboard/DashboardTabPanels.tsx | 19 + .../common/components/dashboard/TabLayout.tsx | 7 +- .../dashboard/components/banner/TabBanner.tsx | 4 +- .../dialog/DeleteConfirmationDialog.scss | 20 +- .../dialog/DeleteConfirmationDialog.tsx | 134 +++---- .../components/form/CustomFormComponents.tsx | 12 +- .../subFormComponents/CustomCheckbox.scss | 22 ++ .../form/subFormComponents/CustomCheckbox.tsx | 43 ++- .../tab => tabs}/TabPanelContainer.tsx | 10 +- .../components/tab => tabs}/TabPanels.tsx | 9 +- .../components/tab => tabs}/TabsList.scss | 2 +- .../components/tab => tabs}/TabsList.tsx | 0 .../tab => tabs}/types/TabComponentProps.ts | 2 +- .../src/common/constants/bannerMessages.ts | 3 + .../common/constants/validation_messages.json | 17 + .../src/common/helpers/validationMessages.ts | 17 + frontend/src/common/types/common.ts | 4 +- .../idir/company/IDIRCreateCompany.scss | 3 + .../idir/company/IDIRCreateCompany.tsx | 3 +- .../search/pages/IDIRReportsDashboard.tsx | 7 +- .../pages/IDIRSearchResultsDashboard.scss | 3 + .../pages/IDIRSearchResultsDashboard.tsx | 8 +- .../features/idir/search/pages/dashboard.scss | 5 +- .../dashboard/ManageProfilesDashboard.tsx | 87 +++-- .../manageProfile/pages/UserManagement.tsx | 8 +- .../manageProfile/types/manageProfile.d.ts | 1 + .../manageVehicles/components/list/List.tsx | 8 +- .../features/permits/components/list/List.tsx | 8 +- .../ApplicationsInProgressList.tsx | 8 +- .../src/features/permits/types/PermitType.ts | 3 + .../apiManager/endpoints/endpoints.ts | 23 +- .../apiManager/specialAuthorization.ts | 131 +++++++ .../AllowedIndicator/AllowedIndicator.scss | 18 + .../AllowedIndicator/AllowedIndicator.tsx | 19 + .../SpecialAuthorizations/LCV/LCVSection.scss | 20 + .../SpecialAuthorizations/LCV/LCVSection.tsx | 38 ++ .../LOA/expired/ExpiredLOAModal.scss | 84 +++++ .../LOA/expired/ExpiredLOAModal.tsx | 68 ++++ .../LOA/list/LOADownloadCell.tsx | 26 ++ .../LOA/list/LOAList.scss | 21 ++ .../LOA/list/LOAList.tsx | 92 +++++ .../LOA/list/LOAListColumnDef.tsx | 96 +++++ .../LOA/list/LOANumberCell.tsx | 29 ++ .../LOA/upload/UploadInput.scss | 49 +++ .../LOA/upload/UploadInput.tsx | 61 +++ .../LOA/upload/UploadedFile.scss | 58 +++ .../LOA/upload/UploadedFile.tsx | 59 +++ .../vehicles/LOAVehicleColumnDefinition.ts | 37 ++ .../LOA/vehicles/LOAVehicleTabLayout.tsx | 31 ++ .../LOA/vehicles/VehicleTable.scss | 46 +++ .../LOA/vehicles/VehicleTable.tsx | 141 +++++++ .../NoFeePermits/NoFeePermitsSection.scss | 45 +++ .../NoFeePermits/NoFeePermitsSection.tsx | 118 ++++++ .../dashboard/ManageSettingsDashboard.tsx | 77 ++-- .../features/settings/helpers/permissions.ts | 152 +++++++- frontend/src/features/settings/hooks/LOA.ts | 119 ++++++ .../SpecialAuthorizations/LOA/LOASteps.scss | 117 ++++++ .../SpecialAuthorizations/LOA/LOASteps.tsx | 228 +++++++++++ .../LOA/basic/LOABasicInfo.scss | 115 ++++++ .../LOA/basic/LOABasicInfo.tsx | 355 ++++++++++++++++++ .../LOA/review/LOAReview.scss | 21 ++ .../LOA/review/LOAReview.tsx | 100 +++++ .../LOA/vehicles/LOADesignateVehicles.scss | 22 ++ .../LOA/vehicles/LOADesignateVehicles.tsx | 296 +++++++++++++++ .../SpecialAuthorizations.scss | 41 ++ .../SpecialAuthorizations.tsx | 276 ++++++++++++++ .../features/settings/types/LOAFormData.ts | 137 +++++++ .../src/features/settings/types/LOAStep.ts | 18 + .../src/features/settings/types/LOAVehicle.ts | 15 + .../features/settings/types/LOAVehicleTab.ts | 6 + .../settings/types/SpecialAuthorization.ts | 61 +++ frontend/src/features/settings/types/tabs.ts | 6 +- .../src/features/wizard/UserInfoWizard.scss | 3 + .../src/features/wizard/UserInfoWizard.tsx | 3 +- .../dashboard/ChallengeProfileSteps.tsx | 3 +- .../dashboard/CreateProfileSteps.scss | 4 + .../dashboard/CreateProfileSteps.tsx | 9 +- frontend/src/routes/Routes.tsx | 3 + frontend/src/themes/orbcStyles.scss | 1 + .../src/modules/loa/profile/loa.profile.ts | 1 + 83 files changed, 3821 insertions(+), 226 deletions(-) create mode 100644 frontend/src/common/components/dashboard/DashboardTabPanels.scss create mode 100644 frontend/src/common/components/dashboard/DashboardTabPanels.tsx create mode 100644 frontend/src/common/components/form/subFormComponents/CustomCheckbox.scss rename frontend/src/common/components/{dashboard/components/tab => tabs}/TabPanelContainer.tsx (56%) rename frontend/src/common/components/{dashboard/components/tab => tabs}/TabPanels.tsx (66%) rename frontend/src/common/components/{dashboard/components/tab => tabs}/TabsList.scss (95%) rename frontend/src/common/components/{dashboard/components/tab => tabs}/TabsList.tsx (100%) rename frontend/src/common/components/{dashboard/components/tab => tabs}/types/TabComponentProps.ts (66%) create mode 100644 frontend/src/features/idir/company/IDIRCreateCompany.scss create mode 100644 frontend/src/features/idir/search/pages/IDIRSearchResultsDashboard.scss create mode 100644 frontend/src/features/settings/apiManager/specialAuthorization.ts create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/AllowedIndicator/AllowedIndicator.scss create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/AllowedIndicator/AllowedIndicator.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LCV/LCVSection.scss create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LCV/LCVSection.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/expired/ExpiredLOAModal.scss create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/expired/ExpiredLOAModal.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOADownloadCell.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAList.scss create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAList.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAListColumnDef.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOANumberCell.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadInput.scss create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadInput.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadedFile.scss create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadedFile.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/LOAVehicleColumnDefinition.ts create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/LOAVehicleTabLayout.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/VehicleTable.scss create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/VehicleTable.tsx create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/NoFeePermits/NoFeePermitsSection.scss create mode 100644 frontend/src/features/settings/components/SpecialAuthorizations/NoFeePermits/NoFeePermitsSection.tsx create mode 100644 frontend/src/features/settings/hooks/LOA.ts create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/LOA/LOASteps.scss create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/LOA/LOASteps.tsx create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/LOA/basic/LOABasicInfo.scss create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/LOA/basic/LOABasicInfo.tsx create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/LOA/review/LOAReview.scss create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/LOA/review/LOAReview.tsx create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/LOA/vehicles/LOADesignateVehicles.scss create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/LOA/vehicles/LOADesignateVehicles.tsx create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/SpecialAuthorizations.scss create mode 100644 frontend/src/features/settings/pages/SpecialAuthorizations/SpecialAuthorizations.tsx create mode 100644 frontend/src/features/settings/types/LOAFormData.ts create mode 100644 frontend/src/features/settings/types/LOAStep.ts create mode 100644 frontend/src/features/settings/types/LOAVehicle.ts create mode 100644 frontend/src/features/settings/types/LOAVehicleTab.ts create mode 100644 frontend/src/features/settings/types/SpecialAuthorization.ts create mode 100644 frontend/src/features/wizard/UserInfoWizard.scss diff --git a/frontend/src/common/apiManager/httpRequestHandler.ts b/frontend/src/common/apiManager/httpRequestHandler.ts index dcda4a8bd..cd67c6087 100644 --- a/frontend/src/common/apiManager/httpRequestHandler.ts +++ b/frontend/src/common/apiManager/httpRequestHandler.ts @@ -186,6 +186,22 @@ export const httpPOSTRequest = (url: string, data: any) => { }); }; +/** + * An HTTP POST Request with file upload. + * @param url The URL of the resource. + * @param data The request payload containing file to upload. + * @returns A Promise with the response from the API. + */ +export const httpPOSTRequestWithFile = (url: string, data: FormData) => { + return axios.post(url, data, { + headers: { + "Content-Type": `multipart/form-data`, + Authorization: getAccessToken(), + "X-Correlation-ID": getCorrelationId(), + }, + }); +}; + /** * A generic HTTP PUT Request * @param url The URL of the resource. @@ -202,6 +218,22 @@ export const httpPUTRequest = (url: string, data: any) => { }); }; +/** + * An HTTP PUT Request with file upload. + * @param url The URL of the resource. + * @param data The request payload containing file to upload. + * @returns A Promise with the response from the API. + */ +export const httpPUTRequestWithFile = (url: string, data: FormData) => { + return axios.put(url, data, { + headers: { + "Content-Type": `multipart/form-data`, + Authorization: getAccessToken(), + "X-Correlation-ID": getCorrelationId(), + }, + }); +}; + /** * HTTP Delete Request * @param url The URL containing the resource id to be deleted. diff --git a/frontend/src/common/components/dashboard/Dashboard.scss b/frontend/src/common/components/dashboard/Dashboard.scss index 01c69ff76..c3010c5d3 100644 --- a/frontend/src/common/components/dashboard/Dashboard.scss +++ b/frontend/src/common/components/dashboard/Dashboard.scss @@ -1,13 +1,5 @@ @import "../../../themes/orbcStyles"; -.tabpanel-container { - padding: 0 8.551vw; - overflow: hidden; - background-color: white; - min-height: calc(100vh - 306px); - height: 100%; -} - .layout-box { padding: 0 8.551vw; } @@ -61,22 +53,36 @@ } @media screen and (max-width: 768px) { - .tabpanel-container { - padding: 0 5.5rem; - } - .layout-box { padding: 0 5.5rem; } } @media (width < 420px) { - .tabpanel-container { + .layout-box { padding: 0 1rem; } +} - .layout-box { - padding: 0 1rem; +@mixin page-tabpanel-container-style($page-tabpanel-container) { + #{$page-tabpanel-container} { + padding: 0 8.551vw; + overflow: hidden; + background-color: white; + min-height: calc(100vh - 306px); + height: 100%; + } + + @media screen and (max-width: 768px) { + #{$page-tabpanel-container} { + padding: 0 5.5rem; + } + } + + @media (width < 420px) { + #{$page-tabpanel-container} { + padding: 0 1rem; + } } } diff --git a/frontend/src/common/components/dashboard/DashboardTabPanels.scss b/frontend/src/common/components/dashboard/DashboardTabPanels.scss new file mode 100644 index 000000000..39246d9fd --- /dev/null +++ b/frontend/src/common/components/dashboard/DashboardTabPanels.scss @@ -0,0 +1,3 @@ +@use "./Dashboard"; + +@include Dashboard.page-tabpanel-container-style(".dashboard-tab-panels"); diff --git a/frontend/src/common/components/dashboard/DashboardTabPanels.tsx b/frontend/src/common/components/dashboard/DashboardTabPanels.tsx new file mode 100644 index 000000000..d63eccb9e --- /dev/null +++ b/frontend/src/common/components/dashboard/DashboardTabPanels.tsx @@ -0,0 +1,19 @@ +import "./DashboardTabPanels.scss"; +import { TabPanels } from "../tabs/TabPanels"; +import { TabComponentProps } from "../tabs/types/TabComponentProps"; + +export const DashboardTabPanels = ({ + value, + componentList, +}: { + value: number; + componentList: TabComponentProps[]; +}) => { + return ( + + ); +}; diff --git a/frontend/src/common/components/dashboard/TabLayout.tsx b/frontend/src/common/components/dashboard/TabLayout.tsx index 2e70c5026..7f7a19030 100644 --- a/frontend/src/common/components/dashboard/TabLayout.tsx +++ b/frontend/src/common/components/dashboard/TabLayout.tsx @@ -1,9 +1,8 @@ import React, { useState } from "react"; -import "./Dashboard.scss"; -import { TabComponentProps } from "./components/tab/types/TabComponentProps"; -import { TabPanels } from "./components/tab/TabPanels"; +import { TabComponentProps } from "../tabs/types/TabComponentProps"; import { TabBanner } from "./components/banner/TabBanner"; +import { DashboardTabPanels } from "./DashboardTabPanels"; interface TabLayoutProps { bannerText: string; @@ -51,7 +50,7 @@ export const TabLayout = React.memo( onTabChange={handleChange} /> - + ); }, diff --git a/frontend/src/common/components/dashboard/components/banner/TabBanner.tsx b/frontend/src/common/components/dashboard/components/banner/TabBanner.tsx index 3d4e5e216..f9c55d897 100644 --- a/frontend/src/common/components/dashboard/components/banner/TabBanner.tsx +++ b/frontend/src/common/components/dashboard/components/banner/TabBanner.tsx @@ -1,8 +1,8 @@ import { Box } from "@mui/material"; import "./TabBanner.scss"; -import { TabsList } from "../tab/TabsList"; -import { TabComponentProps } from "../tab/types/TabComponentProps"; +import { TabsList } from "../../../tabs/TabsList"; +import { TabComponentProps } from "../../../tabs/types/TabComponentProps"; export const TabBanner = ({ bannerText, diff --git a/frontend/src/common/components/dialog/DeleteConfirmationDialog.scss b/frontend/src/common/components/dialog/DeleteConfirmationDialog.scss index c41c871a6..758a7d21b 100644 --- a/frontend/src/common/components/dialog/DeleteConfirmationDialog.scss +++ b/frontend/src/common/components/dialog/DeleteConfirmationDialog.scss @@ -1,7 +1,11 @@ @import "../../../themes/orbcStyles"; .delete-confirmation-dialog { - & &__title { + & &__container { + min-width: 683px; + } + + & &__header { background-color: $bc-background-light-grey; color: $bc-red; font-size: 1.5rem; @@ -16,6 +20,10 @@ color: $bc-messages-red-background; } + & &__title { + margin-left: 1rem; + } + & &__content { border-bottom: none; } @@ -28,7 +36,7 @@ padding: 0 1.5rem 1.5rem 1.5rem; } - .delete-confirmation-btn { + & &__btn { font-size: 1rem; &--cancel { @@ -55,3 +63,11 @@ } } } + +@media (width < 768px) { + .delete-confirmation-dialog { + & &__container { + min-width: auto; + } + } +} diff --git a/frontend/src/common/components/dialog/DeleteConfirmationDialog.tsx b/frontend/src/common/components/dialog/DeleteConfirmationDialog.tsx index f65fac8ed..e0f6a21a6 100644 --- a/frontend/src/common/components/dialog/DeleteConfirmationDialog.tsx +++ b/frontend/src/common/components/dialog/DeleteConfirmationDialog.tsx @@ -1,88 +1,78 @@ -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogTitle from "@mui/material/DialogTitle"; -import DialogContent from "@mui/material/DialogContent"; -import DialogActions from "@mui/material/DialogActions"; -import Typography from "@mui/material/Typography"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Typography, +} from "@mui/material"; import "./DeleteConfirmationDialog.scss"; +import { Nullable } from "../../types/common"; /** - * A stateless confirmation dialog box for Delete Operations. + * Confirmation dialog box for delete operations. */ export const DeleteConfirmationDialog = ({ - isOpen, - onClickDelete, - onClickCancel, - caption, + showDialog, + onDelete, + onCancel, + itemToDelete, + confirmationMsg = "Are you sure you want to delete this? This action cannot be undone.", }: { - /** - * Boolean to control the open and close state of Dialog box. - */ - isOpen: boolean; - /** - * A callback function on clicking delete button. - * @returns void - */ - onClickDelete: () => void; - - /** - * A callback function on clicking cancel button. - * @returns void - */ - onClickCancel: () => void; - /** - * A caption string showing on title of the Dialog box. - * @returns string - */ - caption: string; + showDialog: boolean; + onDelete: () => void; + onCancel: () => void; + itemToDelete: string; + confirmationMsg?: Nullable; }) => { - const title = caption; - return ( -
- - - - - {" "} -   - Delete {title}(s)? - + + + + + + + + Delete {itemToDelete}(s)? + + - - - Are you sure you want to delete this? This action cannot be undone. - - + + + {confirmationMsg} + + - - + + - - - -
+ + + ); }; diff --git a/frontend/src/common/components/form/CustomFormComponents.tsx b/frontend/src/common/components/form/CustomFormComponents.tsx index 3da82f24e..5ed23b957 100644 --- a/frontend/src/common/components/form/CustomFormComponents.tsx +++ b/frontend/src/common/components/form/CustomFormComponents.tsx @@ -97,13 +97,9 @@ export const CustomFormComponent = ({ formState: { errors }, } = useFormContext(); - /** - * Function to check the rules object for either required or required: { value: true} - * @returns true/false depending on field rule object - */ - const isRequired = () => { - if (rules.required === true) return true; - if ((rules.required as any).value === true) return true; + const showOptionalLabel = () => { + if (rules.required === false) return true; + if ((rules.required as any)?.value === false) return true; return false; }; @@ -188,7 +184,7 @@ export const CustomFormComponent = ({ sx={{ fontWeight: "bold", marginBottom: "8px" }} > {label} - {!isRequired() && ( + {showOptionalLabel() && ( (optional) )} {customHelperText && ( diff --git a/frontend/src/common/components/form/subFormComponents/CustomCheckbox.scss b/frontend/src/common/components/form/subFormComponents/CustomCheckbox.scss new file mode 100644 index 000000000..7752de715 --- /dev/null +++ b/frontend/src/common/components/form/subFormComponents/CustomCheckbox.scss @@ -0,0 +1,22 @@ +@import "../../../../themes/orbcStyles"; + +.custom-checkbox { + &#{&} { + display: flex; + flex-direction: row; + align-items: center; + } + + & &__checkbox { + margin: 0; + padding: 0; + + &--invalid { + color: $bc-red; + } + } + + & &__label { + margin: 0 0 0 0.5rem; + } +} diff --git a/frontend/src/common/components/form/subFormComponents/CustomCheckbox.tsx b/frontend/src/common/components/form/subFormComponents/CustomCheckbox.tsx index d151b271a..e3ce04aba 100644 --- a/frontend/src/common/components/form/subFormComponents/CustomCheckbox.tsx +++ b/frontend/src/common/components/form/subFormComponents/CustomCheckbox.tsx @@ -5,7 +5,10 @@ import { FieldPath, Controller, useFormContext, + RegisterOptions, } from "react-hook-form"; + +import "./CustomCheckbox.scss"; import { ORBC_FormTypes } from "../../../types/common"; /** @@ -16,8 +19,9 @@ export interface CustomCheckboxProps { feature: string; label: string; inputProps?: InputHTMLAttributes; - checked: boolean; - handleOnChange: ( + checked?: boolean; + rules?: RegisterOptions; + handleChange?: ( event: ChangeEvent, checked: boolean, ) => void; @@ -30,24 +34,33 @@ export interface CustomCheckboxProps { export const CustomCheckbox = ( props: CustomCheckboxProps, ): JSX.Element => { - const { control, register } = useFormContext(); + const { + control, + register, + } = useFormContext(); + + const className = + `custom-checkbox__checkbox ${props.inputProps?.className ? props.inputProps.className : ""}`; + return ( ( - -
- - {props.label} -
+ rules={props.rules} + render={({ fieldState: { invalid }}) => ( + + + + + {props.label} + )} /> diff --git a/frontend/src/common/components/dashboard/components/tab/TabPanelContainer.tsx b/frontend/src/common/components/tabs/TabPanelContainer.tsx similarity index 56% rename from frontend/src/common/components/dashboard/components/tab/TabPanelContainer.tsx rename to frontend/src/common/components/tabs/TabPanelContainer.tsx index 67869a65c..f41f60142 100644 --- a/frontend/src/common/components/dashboard/components/tab/TabPanelContainer.tsx +++ b/frontend/src/common/components/tabs/TabPanelContainer.tsx @@ -2,19 +2,23 @@ interface TabPanelProps { children?: React.ReactNode; index: number; value: number; + className?: string; } export const TabPanelContainer = (props: TabPanelProps) => { - const { children, value, index, ...other } = props; + const { children, value, index, className } = props; + const baseContainerClassName = "tabpanel-container"; + const containerClassName = className ? + `${baseContainerClassName} ${className}` : baseContainerClassName; + return ( diff --git a/frontend/src/common/components/dashboard/components/tab/TabPanels.tsx b/frontend/src/common/components/tabs/TabPanels.tsx similarity index 66% rename from frontend/src/common/components/dashboard/components/tab/TabPanels.tsx rename to frontend/src/common/components/tabs/TabPanels.tsx index 3d829b354..2fd7e6795 100644 --- a/frontend/src/common/components/dashboard/components/tab/TabPanels.tsx +++ b/frontend/src/common/components/tabs/TabPanels.tsx @@ -4,14 +4,21 @@ import { TabComponentProps } from "./types/TabComponentProps"; export const TabPanels = ({ value, componentList, + containerClassName, }: { value: number; componentList: TabComponentProps[]; + containerClassName?: string; }) => ( <> {componentList.map(({ label, component }, index) => { return ( - + {component} ); diff --git a/frontend/src/common/components/dashboard/components/tab/TabsList.scss b/frontend/src/common/components/tabs/TabsList.scss similarity index 95% rename from frontend/src/common/components/dashboard/components/tab/TabsList.scss rename to frontend/src/common/components/tabs/TabsList.scss index fa639317a..76796bde9 100644 --- a/frontend/src/common/components/dashboard/components/tab/TabsList.scss +++ b/frontend/src/common/components/tabs/TabsList.scss @@ -1,4 +1,4 @@ -@import "../../../../../themes/orbcStyles"; +@import "../../../themes/orbcStyles"; .tabs-list { /* Override to hide disabled scroll buttons */ diff --git a/frontend/src/common/components/dashboard/components/tab/TabsList.tsx b/frontend/src/common/components/tabs/TabsList.tsx similarity index 100% rename from frontend/src/common/components/dashboard/components/tab/TabsList.tsx rename to frontend/src/common/components/tabs/TabsList.tsx diff --git a/frontend/src/common/components/dashboard/components/tab/types/TabComponentProps.ts b/frontend/src/common/components/tabs/types/TabComponentProps.ts similarity index 66% rename from frontend/src/common/components/dashboard/components/tab/types/TabComponentProps.ts rename to frontend/src/common/components/tabs/types/TabComponentProps.ts index 5fed81e43..b56c424a0 100644 --- a/frontend/src/common/components/dashboard/components/tab/types/TabComponentProps.ts +++ b/frontend/src/common/components/tabs/types/TabComponentProps.ts @@ -1,4 +1,4 @@ -import { Nullable } from "../../../../../types/common"; +import { Nullable } from "../../../types/common"; export interface TabComponentProps { label: string; diff --git a/frontend/src/common/constants/bannerMessages.ts b/frontend/src/common/constants/bannerMessages.ts index 80efa76b9..ea8aaf589 100644 --- a/frontend/src/common/constants/bannerMessages.ts +++ b/frontend/src/common/constants/bannerMessages.ts @@ -12,4 +12,7 @@ export const BANNER_MESSAGES = { "The applicant is responsible for ensuring they are following Legislation, policies, standards and guidelines in the operation of a commercial transportation business in British Columbia.", CANNOT_FIND_VEHICLE: "Can't find a vehicle from your inventory?", ISSUED_PERMIT_NUMBER_7_YEARS: "Enter any Permit No. issued to the above Client No. in the last 7 years", + SELECT_VEHICLES_LOA: "Only vehicles in the Vehicle Inventory can be designated to LOA(s).", + SELECT_VEHICLES_LOA_INFO: + "If you do not see the vehicle(s) you wish to designate here, please make sure you add them to the client's Vehicle Inventory first and come back to this page.", }; diff --git a/frontend/src/common/constants/validation_messages.json b/frontend/src/common/constants/validation_messages.json index 99c8b895c..c33cae906 100644 --- a/frontend/src/common/constants/validation_messages.json +++ b/frontend/src/common/constants/validation_messages.json @@ -26,6 +26,11 @@ "messageTemplate": "Start Date must be within :max days.", "placeholders": [":max"] } + }, + "expiry": { + "beforeStart": { + "defaultMessage": "Expiry cannot be before Start Date" + } } }, "email": { @@ -107,5 +112,17 @@ "placeholders": [":max"] } } + }, + "upload": { + "fileSize": { + "exceeded": "File exceeds maximum size" + }, + "fileFormat": { + "defaultMessage": "File format not supported" + }, + "required": { + "messageTemplate": "The :item must be uploaded", + "placeholders": [":item"] + } } } \ No newline at end of file diff --git a/frontend/src/common/helpers/validationMessages.ts b/frontend/src/common/helpers/validationMessages.ts index f8bb29285..5f594e8bc 100644 --- a/frontend/src/common/helpers/validationMessages.ts +++ b/frontend/src/common/helpers/validationMessages.ts @@ -29,6 +29,10 @@ export const invalidMaxStartDate = (max: number) => { return replacePlaceholders(messageTemplate, placeholders, max); }; +export const expiryMustBeAfterStart = () => { + return validationMessages.date.expiry.beforeStart.defaultMessage; +}; + export const invalidEmail = () => validationMessages.email.defaultMessage; export const invalidPhoneLength = (min: number, max: number) => { @@ -84,6 +88,19 @@ export const invalidDBALength = (min: number, max: number) => { return replacePlaceholders(messageTemplate, placeholders, min, max); }; +export const uploadSizeExceeded = () => { + return validationMessages.upload.fileSize.exceeded; +}; + +export const invalidUploadFormat = () => { + return validationMessages.upload.fileFormat.defaultMessage; +}; + +export const requiredUpload = (uploadItem: string) => { + const { messageTemplate, placeholders } = validationMessages.upload.required; + return replacePlaceholders(messageTemplate, placeholders, uploadItem); +}; + /** * Checks if a given string is * null, empty or conforms to length requirements if it has a value. diff --git a/frontend/src/common/types/common.ts b/frontend/src/common/types/common.ts index d06da802d..585a0803b 100644 --- a/frontend/src/common/types/common.ts +++ b/frontend/src/common/types/common.ts @@ -13,6 +13,7 @@ import { PowerUnit, Trailer, } from "../../features/manageVehicles/types/Vehicle"; +import { LOAFormData } from "../../features/settings/types/LOAFormData"; export interface ApiErrorResponse { status: number; @@ -32,7 +33,8 @@ export type ORBC_FormTypes = | BCeIDAddUserRequest | SearchFields | VerifyClientRequest - | PermitContactDetails; + | PermitContactDetails + | LOAFormData; /** * The options for pagination. diff --git a/frontend/src/features/idir/company/IDIRCreateCompany.scss b/frontend/src/features/idir/company/IDIRCreateCompany.scss new file mode 100644 index 000000000..9e725ed09 --- /dev/null +++ b/frontend/src/features/idir/company/IDIRCreateCompany.scss @@ -0,0 +1,3 @@ +@use "../../../common/components/dashboard/Dashboard"; + +@include Dashboard.page-tabpanel-container-style(".idir-create-company"); diff --git a/frontend/src/features/idir/company/IDIRCreateCompany.tsx b/frontend/src/features/idir/company/IDIRCreateCompany.tsx index 1f2636381..302dede49 100644 --- a/frontend/src/features/idir/company/IDIRCreateCompany.tsx +++ b/frontend/src/features/idir/company/IDIRCreateCompany.tsx @@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/react-query"; import { FormProvider, useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; +import "./IDIRCreateCompany.scss"; import { Nullable } from "../../../common/types/common"; import { InfoBcGovBanner } from "../../../common/components/banners/InfoBcGovBanner"; import { Banner } from "../../../common/components/dashboard/components/banner/Banner"; @@ -136,7 +137,7 @@ export const IDIRCreateCompany = React.memo(() => {
diff --git a/frontend/src/features/idir/search/pages/IDIRReportsDashboard.tsx b/frontend/src/features/idir/search/pages/IDIRReportsDashboard.tsx index cba3ebf44..b3bb248cc 100644 --- a/frontend/src/features/idir/search/pages/IDIRReportsDashboard.tsx +++ b/frontend/src/features/idir/search/pages/IDIRReportsDashboard.tsx @@ -1,3 +1,4 @@ +import { memo, useState } from "react"; import { Box, Divider, @@ -6,12 +7,12 @@ import { Radio, Stack, } from "@mui/material"; -import { memo, useState } from "react"; + +import "./dashboard.scss"; import { Banner } from "../../../../common/components/dashboard/components/banner/Banner"; import { BC_COLOURS } from "../../../../themes/bcGovStyles"; import { PaymentAndRefundDetail } from "../../reporting/forms/PaymentAndRefundDetail"; import { PaymentAndRefundSummary } from "../../reporting/forms/PaymentAndRefundSummary"; -import "./dashboard.scss"; /** * The types of reports. @@ -75,7 +76,7 @@ export const IDIRReportsDashboard = memo(() => {
{
{ }); const navigate = useNavigate(); - const { userRoles, companyId: companyIdFromContext } = - useContext(OnRouteBCContext); + + const { + userRoles, + companyId: companyIdFromContext, + idirUserDetails, + userDetails, + } = useContext(OnRouteBCContext); + const companyId = getDefaultRequiredVal(0, companyIdFromContext); - const { user } = useAuth(); const { data: creditAccount } = useGetCreditAccountQuery(companyId); const { data: featureFlags } = useFeatureFlagsQuery(); const populatedUserRoles = getDefaultRequiredVal([], userRoles); - const isIDIRUser = isIDIR(user?.profile?.identity_provider as string); + const isStaffActingAsCompany = Boolean(idirUserDetails?.userAuthGroup); const isBCeIDAdmin = isBCeIDOrgAdmin(populatedUserRoles); - const shouldAllowUserManagement = isBCeIDAdmin || isIDIRUser; + const shouldAllowUserManagement = isBCeIDAdmin || isStaffActingAsCompany; + const showSpecialAuth = !isStaffActingAsCompany && canViewSpecialAuthorizations( + userRoles, + userDetails?.userAuthGroup, + ) && featureFlags?.["LOA"] === "ENABLED"; + const creditAccountHolder = creditAccount?.creditAccountUsers.find( (user) => user.userType === CREDIT_ACCOUNT_USER_TYPE.HOLDER, ); @@ -88,28 +100,31 @@ export const ManageProfilesDashboard = React.memo(() => { component: , componentKey: BCEID_PROFILE_TABS.COMPANY_INFORMATION, }, - !isIDIRUser - ? { - label: "My Information", - component: , - componentKey: BCEID_PROFILE_TABS.MY_INFORMATION, - } - : null, - shouldAllowUserManagement - ? { - label: "Add / Manage Users", - component: , - componentKey: BCEID_PROFILE_TABS.USER_MANAGEMENT, - } - : null, - showCreditAccountTab - ? { - label: "Credit Account", - component: , - componentKey: BCEID_PROFILE_TABS.CREDIT_ACCOUNT, - } - : null, - ].filter((tab) => Boolean(tab)) as ProfileDashboardTab[]; + !isStaffActingAsCompany ? { + label: "My Information", + component: , + componentKey: BCEID_PROFILE_TABS.MY_INFORMATION, + } : null, + shouldAllowUserManagement ? { + label: "Add / Manage Users", + component: , + componentKey: BCEID_PROFILE_TABS.USER_MANAGEMENT, + } : null, + showSpecialAuth && companyId ? { + label: "Special Authorizations", + component: ( + + ), + componentKey: BCEID_PROFILE_TABS.SPECIAL_AUTH, + } : null, + showCreditAccountTab ? { + label: "Credit Account", + component: , + componentKey: BCEID_PROFILE_TABS.CREDIT_ACCOUNT, + } : null, + ].filter(tab => Boolean(tab)) as ProfileDashboardTab[]; const getSelectedTabFromNavigation = (): number => { const tabIndex = tabs.findIndex( @@ -145,12 +160,12 @@ export const ManageProfilesDashboard = React.memo(() => { } if (isError) { - if (error instanceof AxiosError) { - if (error.response?.status === 401) { - return ; - } - return ; - } + const isUnauthorized = error instanceof AxiosError && error.response?.status == 401; + return isUnauthorized ? ( + + ) : ( + + ); } return ( diff --git a/frontend/src/features/manageProfile/pages/UserManagement.tsx b/frontend/src/features/manageProfile/pages/UserManagement.tsx index b19b5b350..ad0ea33b9 100644 --- a/frontend/src/features/manageProfile/pages/UserManagement.tsx +++ b/frontend/src/features/manageProfile/pages/UserManagement.tsx @@ -204,10 +204,10 @@ export const UserManagement = () => {
); diff --git a/frontend/src/features/manageProfile/types/manageProfile.d.ts b/frontend/src/features/manageProfile/types/manageProfile.d.ts index 2e40dc252..ac79d570b 100644 --- a/frontend/src/features/manageProfile/types/manageProfile.d.ts +++ b/frontend/src/features/manageProfile/types/manageProfile.d.ts @@ -173,5 +173,6 @@ export const BCEID_PROFILE_TABS = { MY_INFORMATION: "MyInformationTab", USER_MANAGEMENT: "UserManagementTab", PAYMENT_INFORMATION: "PaymentInformationTab", + SPECIAL_AUTH: "SpecialAuthorizationsTab", CREDIT_ACCOUNT: "CreditAccountTab", } as const; diff --git a/frontend/src/features/manageVehicles/components/list/List.tsx b/frontend/src/features/manageVehicles/components/list/List.tsx index d1bad65b3..3477ae4b5 100644 --- a/frontend/src/features/manageVehicles/components/list/List.tsx +++ b/frontend/src/features/manageVehicles/components/list/List.tsx @@ -338,10 +338,10 @@ export const List = memo(
); diff --git a/frontend/src/features/permits/components/list/List.tsx b/frontend/src/features/permits/components/list/List.tsx index efacbcd0a..86377e506 100644 --- a/frontend/src/features/permits/components/list/List.tsx +++ b/frontend/src/features/permits/components/list/List.tsx @@ -203,10 +203,10 @@ export const List = memo(
); diff --git a/frontend/src/features/permits/components/permit-list/ApplicationsInProgressList.tsx b/frontend/src/features/permits/components/permit-list/ApplicationsInProgressList.tsx index 6c0107000..ec2d4f860 100644 --- a/frontend/src/features/permits/components/permit-list/ApplicationsInProgressList.tsx +++ b/frontend/src/features/permits/components/permit-list/ApplicationsInProgressList.tsx @@ -248,10 +248,10 @@ export const ApplicationsInProgressList = ({ )}
); diff --git a/frontend/src/features/permits/types/PermitType.ts b/frontend/src/features/permits/types/PermitType.ts index 13b829d86..df4fc1046 100644 --- a/frontend/src/features/permits/types/PermitType.ts +++ b/frontend/src/features/permits/types/PermitType.ts @@ -18,6 +18,7 @@ export const PERMIT_TYPES = { NRSFV: "NRSFV", NRSXP: "NRSXP", RIG: "RIG", + STOL: "STOL", STOS: "STOS", STOW: "STOW", STWS: "STWS", @@ -72,6 +73,8 @@ export const getPermitTypeName = (permitType?: Nullable) => { return "Non Resident Single Trip X Plated Vehicle"; case PERMIT_TYPES.RIG: return "Rig Move"; + case PERMIT_TYPES.STOL: + return "Single Trip Over Length"; case PERMIT_TYPES.STOS: return "Single Trip Oversize"; case PERMIT_TYPES.STOW: diff --git a/frontend/src/features/settings/apiManager/endpoints/endpoints.ts b/frontend/src/features/settings/apiManager/endpoints/endpoints.ts index af1221a51..051eb8d8d 100644 --- a/frontend/src/features/settings/apiManager/endpoints/endpoints.ts +++ b/frontend/src/features/settings/apiManager/endpoints/endpoints.ts @@ -1,12 +1,33 @@ import { VEHICLES_URL } from "../../../../common/apiManager/endpoints/endpoints"; -const SUSPEND_API_BASE = `${VEHICLES_URL}/companies`; +const SETTINGS_API_BASE = `${VEHICLES_URL}/companies`; +const SUSPEND_API_BASE = SETTINGS_API_BASE; +const SPECIAL_AUTH_API_BASE = SETTINGS_API_BASE; export const SUSPEND_API_ROUTES = { HISTORY: (companyId: number) => `${SUSPEND_API_BASE}/${companyId}/suspend`, SUSPEND: (companyId: number) => `${SUSPEND_API_BASE}/${companyId}/suspend`, }; +export const SPECIAL_AUTH_API_ROUTES = { + LOA: { + ALL: (companyId: number | string, expired: boolean) => + `${SPECIAL_AUTH_API_BASE}/${companyId}/loas${expired ? "?expired=true" : ""}`, + DETAIL: (companyId: number | string, loaId: string) => + `${SPECIAL_AUTH_API_BASE}/${companyId}/loas/${loaId}`, + CREATE: (companyId: number | string) => + `${SPECIAL_AUTH_API_BASE}/${companyId}/loas`, + UPDATE: (companyId: number | string, loaId: string) => + `${SPECIAL_AUTH_API_BASE}/${companyId}/loas/${loaId}`, + REMOVE: (companyId: number | string, loaId: string) => + `${SPECIAL_AUTH_API_BASE}/${companyId}/loas/${loaId}`, + DOWNLOAD: (companyId: number | string, loaId: string) => + `${SPECIAL_AUTH_API_BASE}/${companyId}/loas/${loaId}/documents?download=proxy`, + REMOVE_DOCUMENT: (companyId: number | string, loaId: string) => + `${SPECIAL_AUTH_API_BASE}/${companyId}/loas/${loaId}/documents`, + }, +}; + const CREDIT_ACCOUNT_API_BASE = `${VEHICLES_URL}/companies`; export const CREDIT_ACCOUNT_API_ROUTES = { diff --git a/frontend/src/features/settings/apiManager/specialAuthorization.ts b/frontend/src/features/settings/apiManager/specialAuthorization.ts new file mode 100644 index 000000000..5876c7555 --- /dev/null +++ b/frontend/src/features/settings/apiManager/specialAuthorization.ts @@ -0,0 +1,131 @@ +import { AxiosResponse } from "axios"; + +import { LOADetail } from "../types/SpecialAuthorization"; +import { LOAFormData, serializeLOAFormData } from "../types/LOAFormData"; +import { SPECIAL_AUTH_API_ROUTES } from "./endpoints/endpoints"; +import { streamDownloadFile } from "../../../common/helpers/util"; +import { + httpDELETERequest, + httpGETRequest, + httpGETRequestStream, + httpPOSTRequestWithFile, + httpPUTRequestWithFile, +} from "../../../common/apiManager/httpRequestHandler"; + +/** + * Get the LOAs for a given company. + * @param companyId Company id of the company to get LOAs for + * @param expired Whether or not to only fetch expired LOAs + * @returns LOAs for the given company + */ +export const getLOAs = async ( + companyId: number | string, + expired: boolean, +): Promise => { + const response = await httpGETRequest( + SPECIAL_AUTH_API_ROUTES.LOA.ALL(companyId, expired), + ); + return response.data; +}; + +/** + * Get the LOA detail for a specific LOA. + * @param companyId Company id of the company to get LOA for + * @param loaId id of the LOA to fetch + * @returns LOA detail for a given LOA + */ +export const getLOADetail = async ( + companyId: number | string, + loaId: string, +): Promise => { + const response = await httpGETRequest( + SPECIAL_AUTH_API_ROUTES.LOA.DETAIL(companyId, loaId), + ); + return response.data; +}; + +/** + * Create an LOA for a company. + * @param LOAData Information about the LOA to be created for the company + * @returns Result of creating the LOA, or error on fail + */ +export const createLOA = async ( + LOAData: { + companyId: number | string; + data: LOAFormData; + }, +): Promise> => { + const { companyId, data } = LOAData; + return await httpPOSTRequestWithFile( + SPECIAL_AUTH_API_ROUTES.LOA.CREATE(companyId), + serializeLOAFormData(data), + ); +}; + +/** + * Update an LOA for a company. + * @param LOAData Information about the LOA to be updated for the company + * @returns Result of updating the LOA, or error on fail + */ +export const updateLOA = async ( + LOAData: { + companyId: number | string; + loaId: string; + data: LOAFormData; + }, +): Promise> => { + const { companyId, loaId, data } = LOAData; + return await httpPUTRequestWithFile( + SPECIAL_AUTH_API_ROUTES.LOA.UPDATE(companyId, loaId), + serializeLOAFormData(data), + ); +}; + +/** + * Remove an LOA for a company. + * @param LOAData LOA id and id of the company to remove it from + * @returns Result of removing the LOA, or error on fail + */ +export const removeLOA = async ( + LOAData: { + companyId: number | string; + loaId: string; + }, +): Promise> => { + const { companyId, loaId } = LOAData; + return await httpDELETERequest( + SPECIAL_AUTH_API_ROUTES.LOA.REMOVE(companyId, loaId), + ); +}; + +/** + * Download LOA. + * @param loaId id of the LOA to download + * @param companyId id of the company that the LOA belongs to + * @returns A Promise containing the dms reference string for the LOA download stream + */ +export const downloadLOA = async ( + loaId: string, + companyId: string | number, +) => { + const url = SPECIAL_AUTH_API_ROUTES.LOA.DOWNLOAD(companyId, loaId); + const response = await httpGETRequestStream(url); + return await streamDownloadFile(response); +}; + +/** + * Remove an LOA document. + * @param LOAData LOA id and id of the company to remove it from + * @returns Result of removing the LOA document, or error on fail + */ +export const removeLOADocument = async ( + LOAData: { + companyId: number | string; + loaId: string; + }, +): Promise> => { + const { companyId, loaId } = LOAData; + return await httpDELETERequest( + SPECIAL_AUTH_API_ROUTES.LOA.REMOVE_DOCUMENT(companyId, loaId), + ); +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/AllowedIndicator/AllowedIndicator.scss b/frontend/src/features/settings/components/SpecialAuthorizations/AllowedIndicator/AllowedIndicator.scss new file mode 100644 index 000000000..13333754a --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/AllowedIndicator/AllowedIndicator.scss @@ -0,0 +1,18 @@ +@import "../../../../../themes/orbcStyles"; + +.allowed-indicator { + display: flex; + align-items: center; + + & &__icon { + font-size: 1.5rem; + color: $bc-green; + } + + & &__label { + margin-left: 0.5rem; + font-size: 1.25rem; + font-weight: bold; + color: $bc-green; + } +} diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/AllowedIndicator/AllowedIndicator.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/AllowedIndicator/AllowedIndicator.tsx new file mode 100644 index 000000000..bfce7febf --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/AllowedIndicator/AllowedIndicator.tsx @@ -0,0 +1,19 @@ +import { faCircleCheck } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import "./AllowedIndicator.scss"; + +export const AllowedIndicator = () => { + return ( +
+ + + + Allowed + +
+ ); +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LCV/LCVSection.scss b/frontend/src/features/settings/components/SpecialAuthorizations/LCV/LCVSection.scss new file mode 100644 index 000000000..b319a8a99 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LCV/LCVSection.scss @@ -0,0 +1,20 @@ +@import "../../../../../themes/orbcStyles"; + +.lcv-section { + padding: 2.5rem 0; + border-bottom: 1px solid $bc-border-grey; + max-width: 59rem; + width: 100%; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + } + + &__title { + font-weight: bold; + font-size: 1.25rem; + } +} diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LCV/LCVSection.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LCV/LCVSection.tsx new file mode 100644 index 000000000..7b848b509 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LCV/LCVSection.tsx @@ -0,0 +1,38 @@ +import { Switch } from "@mui/material"; + +import "./LCVSection.scss"; +import { AllowedIndicator } from "../AllowedIndicator/AllowedIndicator"; + +export const LCVSection = ({ + enableLCV, + setEnableLCV, + isEditable = false, +}: { + enableLCV: boolean; + setEnableLCV: (enable: boolean) => void; + isEditable?: boolean; +}) => { + return (isEditable || enableLCV) ? ( +
+
+
+ Long Combination Vehicle (LCV) +
+ + {isEditable ? ( + setEnableLCV(checked)} + /> + ) : ( + + )} +
+ +
+ Carrier meets the requirements to operate LCVs in BC. +
+
+ ) : null; +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/expired/ExpiredLOAModal.scss b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/expired/ExpiredLOAModal.scss new file mode 100644 index 000000000..3f79c5708 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/expired/ExpiredLOAModal.scss @@ -0,0 +1,84 @@ +@import "../../../../../../themes/orbcStyles"; + +.expired-loa-modal { + & &__container { + width: 100%; + max-width: 900px; + display: flex; + flex-direction: column; + } + + &__header { + padding: 1.5rem; + display: flex; + flex-direction: row; + align-items: center; + background-color: $bc-background-light-grey; + } + + &__icon { + border-radius: 50%; + background-color: $bc-black; + padding: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + + .icon { + color: $white; + height: 1.5rem; + } + } + + &__title { + font-weight: bold; + font-size: 1.5rem; + margin-left: 1rem; + color: $bc-black; + } + + &__body { + padding: 1.5rem; + display: flex; + flex-direction: column; + align-items: flex-start; + } + + &__msg { + margin: 0 0 1.5rem 0; + color: $bc-black; + font-size: 1.25rem; + font-weight: bold; + } + + &__banner { + margin-bottom: 1.5rem; + } + + .loa-list { + width: 100%; + } + + &__footer { + display: flex; + flex-direction: row; + justify-content: flex-end; + padding: 0 1.5rem 1.5rem 1.5rem; + + .expired-loa-button { + &--cancel { + cursor: pointer; + background-color: $bc-background-light-grey; + color: $bc-black; + border: none; + + &:hover { + border: 2px solid $bc-text-box-border-grey; + background-color: $bc-background-light-grey; + cursor: pointer; + box-shadow: none; + } + } + } + } +} diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/expired/ExpiredLOAModal.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/expired/ExpiredLOAModal.tsx new file mode 100644 index 000000000..8d90836ea --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/expired/ExpiredLOAModal.tsx @@ -0,0 +1,68 @@ +import { Button, Dialog } from "@mui/material"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faClockRotateLeft } from "@fortawesome/free-solid-svg-icons"; + +import "./ExpiredLOAModal.scss"; +import { LOAList } from "../list/LOAList"; +import { LOADetail } from "../../../../types/SpecialAuthorization"; + +export const ExpiredLOAModal = ({ + showModal, + allowEditLOA, + handleCancel, + handleEdit, + handleDownload, + expiredLOAs, +}: { + showModal: boolean; + allowEditLOA: boolean; + handleCancel: () => void; + handleEdit: (loaId: string) => void; + handleDownload: (loaId: string) => void; + expiredLOAs: LOADetail[]; +}) => { + return ( + +
+
+ +
+ + + Expired LOA(s) + +
+ +
+ +
+ +
+ +
+
+ ); +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOADownloadCell.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOADownloadCell.tsx new file mode 100644 index 000000000..386b6215f --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOADownloadCell.tsx @@ -0,0 +1,26 @@ +import { MRT_Row } from "material-react-table"; + +import { CustomActionLink } from "../../../../../../common/components/links/CustomActionLink"; +import { LOADetail } from "../../../../types/SpecialAuthorization"; + +export const LOADownloadCell = ({ + onDownload, + props: { row }, +}: { + onDownload: (loaId: string) => void; + props: { + row: MRT_Row; + }; +}) => { + const loaId = `${row.original.loaId}`; + const loaHasDocument = Boolean(row.original.documentId); + + return loaHasDocument ? ( + onDownload(loaId)} + > + Download Letter + + ) : null; +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAList.scss b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAList.scss new file mode 100644 index 000000000..fdbe24aa0 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAList.scss @@ -0,0 +1,21 @@ +@import "../../../../../../themes/orbcStyles"; + +.loa-list { + &#{&} { + border: 1px solid $bc-border-grey; + box-shadow: none; + } + + & &__row-actions { + display: flex; + justify-content: flex-end; + } + + & &__delete-btn { + color: $bc-black; + } + + & &__link { + color: $bc-text-links-blue; + } +} diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAList.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAList.tsx new file mode 100644 index 000000000..0383c1db8 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAList.tsx @@ -0,0 +1,92 @@ +import { useCallback } from "react"; +import { IconButton, Tooltip } from "@mui/material"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; +import { + MRT_Row, + MaterialReactTable, + useMaterialReactTable, +} from "material-react-table"; + +import "./LOAList.scss"; +import { LOADetail } from "../../../../types/SpecialAuthorization"; +import { LOAListColumnDef } from "./LOAListColumnDef"; +import { + defaultTableInitialStateOptions, + defaultTableOptions, + defaultTableStateOptions, +} from "../../../../../../common/helpers/tableHelper"; + +export const LOAList = ({ + loas, + isActive, + allowEditLOA, + onEdit, + onDelete, + onDownload, +}: { + loas: LOADetail[]; + isActive: boolean; + allowEditLOA: boolean; + onEdit: (loaId: string) => void; + onDelete?: (loaId: string) => void; + onDownload: (loaId: string) => void; +}) => { + const handleEditLOA = (loaId: string) => { + if (!allowEditLOA) return; + onEdit(loaId); + }; + + const columns = LOAListColumnDef(allowEditLOA, handleEditLOA, onDownload); + + const table = useMaterialReactTable({ + ...defaultTableOptions, + columns, + data: loas, + enableRowActions: isActive, + renderRowActions: useCallback( + ({ + row, + }: { + row: MRT_Row; + }) => isActive && allowEditLOA ? ( +
+ + { + if (!isActive) return; + onDelete?.(row.original.loaId); + }} + disabled={false} + > + + + +
+ ) : null, + [isActive, allowEditLOA], + ), + enableGlobalFilter: false, + enableTopToolbar: false, + enableBottomToolbar: false, + enableRowSelection: false, + initialState: { + ...defaultTableInitialStateOptions, + showGlobalFilter: false, + }, + state: { + ...defaultTableStateOptions, + }, + muiTablePaperProps: { + className: "loa-list", + }, + muiTableContainerProps: { + className: "loa-list__table", + }, + }); + + return ; +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAListColumnDef.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAListColumnDef.tsx new file mode 100644 index 000000000..a365964ad --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOAListColumnDef.tsx @@ -0,0 +1,96 @@ +import { MRT_ColumnDef, MRT_Row } from "material-react-table"; + +import { LOADetail } from "../../../../types/SpecialAuthorization"; +import { DATE_FORMATS, toLocal } from "../../../../../../common/helpers/formatDate"; +import { applyWhenNotNullable } from "../../../../../../common/helpers/util"; +import { LOANumberCell } from "./LOANumberCell"; +import { LOADownloadCell } from "./LOADownloadCell"; + +export const LOAListColumnDef = ( + allowEditLOA: boolean, + onEditLOA: (loaId: string) => void, + onDownload: (loaId: string) => void, +): MRT_ColumnDef[] => [ + { + Cell: ( + props: { row: MRT_Row }, + ) => ( + + ), + accessorKey: "loaNumber", + header: "LOA #", + muiTableHeadCellProps: { + className: + "loa-list__header loa-list__header--number", + }, + muiTableBodyCellProps: { + className: + "loa-list__data loa-list__data--number", + }, + enableSorting: false, + enableColumnActions: false, + }, + { + accessorFn: (originalRow) => { + return toLocal(originalRow.startDate, DATE_FORMATS.DATEONLY_SLASH); + }, + id: "startDate", + header: "Start Date", + muiTableHeadCellProps: { + className: + "loa-list__header loa-list__header--start", + }, + muiTableBodyCellProps: { + className: + "loa-list__data loa-list__data--start", + }, + enableSorting: false, + enableColumnActions: false, + }, + { + accessorFn: (originalRow) => + applyWhenNotNullable( + (expiryDate) => toLocal(expiryDate, DATE_FORMATS.DATEONLY_SLASH), + originalRow.expiryDate, + "Never expires", + ) as string, + id: "expiryDate", + header: "Expiry Date", + muiTableHeadCellProps: { + className: + "loa-list__header loa-list__header--expiry", + }, + muiTableBodyCellProps: { + className: + "loa-list__data loa-list__data--expiry", + }, + enableSorting: false, + enableColumnActions: false, + }, + { + Cell: ( + props: { row: MRT_Row }, + ) => ( + + ), + header: "", + muiTableHeadCellProps: { + className: + "loa-list__header loa-list__header--download", + }, + muiTableBodyCellProps: { + className: + "loa-list__data loa-list__data--download", + }, + accessorKey: "documentId", + enableSorting: false, + enableColumnActions: false, + }, +]; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOANumberCell.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOANumberCell.tsx new file mode 100644 index 000000000..b8184d0e6 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/list/LOANumberCell.tsx @@ -0,0 +1,29 @@ +import { MRT_Row } from "material-react-table"; + +import { LOADetail } from "../../../../types/SpecialAuthorization"; +import { CustomActionLink } from "../../../../../../common/components/links/CustomActionLink"; + +export const LOANumberCell = ({ + allowEditLOA, + onEditLOA, + props: { row }, +}: { + allowEditLOA: boolean; + onEditLOA: (loaId: string) => void; + props: { + row: MRT_Row; + }; +}) => { + const loaId = `${row.original.loaId}`; + const loaNumber = `${row.original.loaNumber}`; + return allowEditLOA ? ( + onEditLOA(loaId)} + > + {loaNumber} + + ) : ( + <>{loaNumber} + ); +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadInput.scss b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadInput.scss new file mode 100644 index 000000000..836c9e366 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadInput.scss @@ -0,0 +1,49 @@ +@import "../../../../../../themes/orbcStyles"; + +.upload-input { + display: flex; + flex-direction: column; + align-items: center; + padding: 1.5rem 0; + background-color: $bc-background-light-grey; + border: 2px solid $bc-background-light-grey; + border-radius: 0.125rem; + color: $bc-black; + + &__icon { + padding: 0.75rem; + border-radius: 50%; + background-color: $bc-background-secondary-grey; + + .icon { + font-size: 1.5rem; + } + } + + &__msg { + display: flex; + margin-top: 1.5rem; + + .upload-info { + &--extension { + font-weight: bold; + padding: 0 0.25rem; + } + } + + .custom-action-link { + margin-left: 0.25rem; + color: $bc-text-links-blue; + + &:hover { + cursor: pointer; + } + } + } + + &__size { + color: $bc-form-elements-grey; + font-size: 0.875rem; + margin-top: 0.5rem; + } +} diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadInput.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadInput.tsx new file mode 100644 index 000000000..e43fad020 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadInput.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { faUpload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import "./UploadInput.scss"; +import { CustomActionLink } from "../../../../../../common/components/links/CustomActionLink"; + +export const UploadInput = ({ + onChooseFile, +}: { + onChooseFile: (file: File) => void; +}) => { + const handleSelectFile = (event: React.ChangeEvent) => { + const selectedFiles = event.target.files; + if (!selectedFiles || selectedFiles.length === 0) return; + onChooseFile(selectedFiles[0]); + }; + + return ( +
+
+ +
+ +
+ + Upload file + + + + (PDF only) + + + + from computer + + + + + +
+ +
+ Maximum file size 10MB +
+
+ ); +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadedFile.scss b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadedFile.scss new file mode 100644 index 000000000..fcbcf8ee0 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadedFile.scss @@ -0,0 +1,58 @@ +@import "../../../../../../themes/orbcStyles"; + +.uploaded-file { + display: flex; + align-items: center; + color: $bc-black; + + &__icon { + padding: 0.5rem 0.75rem; + background-color: $bc-background-secondary-grey; + border-radius: 50%; + + .icon { + font-size: 1.5rem; + color: $bc-black; + } + } + + &__info { + margin-left: 1rem; + width: 100%; + } + + &__size { + color: $bc-form-elements-grey; + font-size: 0.875rem; + } + + &__delete-btn { + position: relative; + padding: 0.75rem; + + .delete-icon { + color: $bc-black; + font-size: 1.5rem; + } + + .tooltip { + top: calc(100% + 0.25rem); + font-size: 1rem; + padding: 0.5rem; + font-family: $default-font; + color: $white; + background-color: $bc-black; + font-weight: normal; + border-radius: 0.25rem; + position: absolute; + width: fit-content; + width: -moz-fit-content; /* For Firefox, Firefox Android */ + } + + &:hover { + background-color: $bc-background-light-grey; + font-weight: bold; + border-radius: 0.25rem; + } + } +} diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadedFile.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadedFile.tsx new file mode 100644 index 000000000..b527def23 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/upload/UploadedFile.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { faFile, faTrashCan } from "@fortawesome/free-regular-svg-icons"; +import { faTrashCan as faTrashHover } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IconButton } from "@mui/material"; + +import "./UploadedFile.scss"; + +export const UploadedFile = ({ + fileName, + onDelete, +}: { + fileName: string; + onDelete: () => void; +}) => { + const [deleteOnHover, setDeleteOnHover] = useState(false); + + return ( +
+
+ +
+ +
+
+ {fileName} +
+
+ + setDeleteOnHover(true)} + onMouseLeave={() => setDeleteOnHover(false)} + disabled={false} + > + {deleteOnHover ? ( + + ) : ( + + )} + + {deleteOnHover ? ( +
+ Delete +
+ ) : null} +
+
+ ); +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/LOAVehicleColumnDefinition.ts b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/LOAVehicleColumnDefinition.ts new file mode 100644 index 000000000..6dde64852 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/LOAVehicleColumnDefinition.ts @@ -0,0 +1,37 @@ +import { MRT_ColumnDef } from "material-react-table"; + +import { LOAVehicle } from "../../../../types/LOAVehicle"; +import { getDefaultRequiredVal } from "../../../../../../common/helpers/util"; + +export const LOAVehicleColumnDefinition: MRT_ColumnDef[] = [ + { + accessorKey: "unitNumber", + header: "Unit #", + size: 150, + }, + { + accessorKey: "make", + header: "Make", + size: 150, + }, + { + accessorKey: "vin", + header: "VIN", + size: 150, + }, + { + accessorKey: "plate", + header: "Plate", + size: 150, + }, + { + header: "Vehicle Sub-type", + accessorFn: (originalRow) => + getDefaultRequiredVal( + "", + originalRow.vehicleSubType.type, + ), + id: "vehicleSubType", + size: 200, + }, +]; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/LOAVehicleTabLayout.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/LOAVehicleTabLayout.tsx new file mode 100644 index 000000000..748470344 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/LOAVehicleTabLayout.tsx @@ -0,0 +1,31 @@ +import { TabPanels } from "../../../../../../common/components/tabs/TabPanels"; +import { TabsList } from "../../../../../../common/components/tabs/TabsList"; +import { LOAVehicleTab } from "../../../../types/LOAVehicleTab"; + +export const LOAVehicleTabLayout = ({ + tabComponents, + selectedTabIndex, + onTabChange, +}: { + tabComponents: { + label: string; + component: JSX.Element; + }[]; + selectedTabIndex: LOAVehicleTab; + onTabChange: (tabIndex: number) => void; +}) => { + return ( +
+ onTabChange(newTab)} + /> + + +
+ ); +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/VehicleTable.scss b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/VehicleTable.scss new file mode 100644 index 000000000..c7b2bad9a --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/VehicleTable.scss @@ -0,0 +1,46 @@ +@import "../../../../../../themes/orbcStyles"; + +.loa-vehicle-table { + color: $bc-black; + + &.table-container { + padding-bottom: 0; + margin-top: 0; + } + + &__search { + .search-label { + font-weight: bold; + margin-bottom: 0.5rem; + + &--light { + font-weight: normal; + } + } + + .search-input { + &__input-container { + border: 2px solid $bc-text-box-border-grey; + + fieldset { + border: none; + } + } + } + } + + & &__container { + border: 1px solid $bc-border-grey; + width: auto; + } + + & &__paper-container { + border-radius: 0; + } + + & &__checkbox { + &--error { + color: $bc-red; + } + } +} diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/VehicleTable.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/VehicleTable.tsx new file mode 100644 index 000000000..68a0f0bd6 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/LOA/vehicles/VehicleTable.tsx @@ -0,0 +1,141 @@ +import { useCallback } from "react"; +import { + MRT_GlobalFilterTextField, + MRT_Row, + MRT_RowSelectionState, + MRT_TableInstance, + MaterialReactTable, + useMaterialReactTable, +} from "material-react-table"; + +import "./VehicleTable.scss"; +import { LOAVehicleColumnDefinition } from "./LOAVehicleColumnDefinition"; +import { NoRecordsFound } from "../../../../../../common/components/table/NoRecordsFound"; +import { LOAVehicle } from "../../../../types/LOAVehicle"; +import { + defaultTableOptions, + defaultTableInitialStateOptions, + defaultTableStateOptions, +} from "../../../../../../common/helpers/tableHelper"; + +export const VehicleTable = ({ + vehicles, + enablePagination = false, + selectedVehicles, + onUpdateSelection, + enableTopToolbar = false, + hasError, +}: { + vehicles: LOAVehicle[]; + enablePagination?: boolean; + selectedVehicles?: { + [id: string]: boolean; + }; + onUpdateSelection?: (selectedVehicles: { [id: string]: boolean; }) => void; + enableTopToolbar?: boolean; + hasError?: boolean; +}) => { + const enableRowSelection = Boolean(onUpdateSelection); + const enableFilter = Boolean(enableTopToolbar); + + const onRowSelectionChange = useCallback( + (updaterFn?: ((old: MRT_RowSelectionState) => MRT_RowSelectionState)) => { + if (updaterFn && onUpdateSelection && selectedVehicles) { + onUpdateSelection(updaterFn(selectedVehicles)); + } + }, + [selectedVehicles, onUpdateSelection] + ); + + const selectionConfig = enableRowSelection ? { + enableRowSelection: true, + muiSelectCheckboxProps: { + className: `loa-vehicle-table__checkbox ${hasError ? "loa-vehicle-table__checkbox--error" : ""}`, + }, + onRowSelectionChange, + } : { + enableRowSelection: false, + }; + + const renderTopToolbar = useCallback( + ({ table }: { table: MRT_TableInstance }) => enableFilter ? ( +
+
+
+ Search VIN (last 6 digits) or Plate +
+ + +
+
+ ) : null, + [enableFilter], + ); + + const filterConfig = enableFilter ? { + enableTopToolbar: true, + filterFns: { + filterByVINAndPlate: (row: MRT_Row, columnId: string, filterValue: string) => { + return (columnId === "plate" && row.getValue(columnId).includes(filterValue)) + || (columnId === "vin" && row.getValue(columnId).endsWith(filterValue)); + }, + }, + globalFilterFn: enableTopToolbar ? "filterByVINAndPlate" : undefined, + renderTopToolbar, + } : { + enableTopToolbar: false, + }; + + const initialState = enableTopToolbar ? { + ...defaultTableInitialStateOptions, + } : { + showGlobalFilter: false, + }; + + const state = enableRowSelection ? { + ...defaultTableStateOptions, + rowSelection: selectedVehicles, + } : { + ...defaultTableStateOptions, + }; + + const table = useMaterialReactTable({ + ...defaultTableOptions, + data: vehicles, + columns: LOAVehicleColumnDefinition, + initialState, + state, + ...filterConfig, + ...selectionConfig, + enablePagination, + enableBottomToolbar: enablePagination, + enableSorting: false, + enableRowActions: false, + getRowId: (originalRow) => { + return `${originalRow.vehicleType}-${originalRow.vehicleId}`; + }, + renderEmptyRowsFallback: () => , + muiTableContainerProps: { + className: "loa-vehicle-table__container", + }, + muiTablePaperProps: { + className: "loa-vehicle-table__paper-container", + }, + }); + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/NoFeePermits/NoFeePermitsSection.scss b/frontend/src/features/settings/components/SpecialAuthorizations/NoFeePermits/NoFeePermitsSection.scss new file mode 100644 index 000000000..0197f9abe --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/NoFeePermits/NoFeePermitsSection.scss @@ -0,0 +1,45 @@ +@import "../../../../../themes/orbcStyles"; + +.no-fee-permits-section { + padding: 1.5rem 0 2.5rem 0; + border-bottom: 1px solid $bc-border-grey; + max-width: 59rem; + width: 100%; + + &#{&}--readonly { + padding: 2.5rem 0; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + } + + &__title { + font-weight: bold; + font-size: 1.25rem; + } +} + +.no-fee-options { + & &__title { + font-weight: bold; + margin-bottom: 1rem; + } + + &__type { + .no-fee-options &#{&}--disabled { + .no-fee-options { + &__radio { + color: $disabled-colour; + } + + &__label { + color: $disabled-colour; + } + } + } + } +} diff --git a/frontend/src/features/settings/components/SpecialAuthorizations/NoFeePermits/NoFeePermitsSection.tsx b/frontend/src/features/settings/components/SpecialAuthorizations/NoFeePermits/NoFeePermitsSection.tsx new file mode 100644 index 000000000..6fb4ee9e4 --- /dev/null +++ b/frontend/src/features/settings/components/SpecialAuthorizations/NoFeePermits/NoFeePermitsSection.tsx @@ -0,0 +1,118 @@ +import { + FormControlLabel, + Radio, + RadioGroup, + Switch, +} from "@mui/material"; + +import "./NoFeePermitsSection.scss"; +import { RequiredOrNull } from "../../../../../common/types/common"; +import { AllowedIndicator } from "../AllowedIndicator/AllowedIndicator"; +import { + NO_FEE_PERMIT_TYPES, + NoFeePermitType, + noFeePermitTypeDescription, +} from "../../../types/SpecialAuthorization"; + +export const NoFeePermitsSection = ({ + enableNoFeePermits, + setEnableNoFeePermits, + noFeePermitType, + setNoFeePermitType, + isEditable = false, +}: { + enableNoFeePermits: boolean; + setEnableNoFeePermits: (enable: boolean) => void; + noFeePermitType: RequiredOrNull; + setNoFeePermitType: (noFeeType: RequiredOrNull) => void; + isEditable?: boolean; +}) => { + if (isEditable) { + return ( +
+
+
+ No Fee Permits +
+ + setEnableNoFeePermits(checked)} + /> +
+ +
+
+ Permits are required, but no fee is charged for a vehicle owned or leased or operated by: +
+ + setNoFeePermitType(Number(e.target.value) as NoFeePermitType)} + > + {Object.values(NO_FEE_PERMIT_TYPES).map((noFeePermitType) => ( + } + /> + ))} + +
+
+ ); + } + + return (enableNoFeePermits && noFeePermitType) ? ( +
+
+
+ No Fee Permits +
+ + +
+ +
+
+ Permits are required, but no fee is charged for a vehicle owned or leased or operated by: +
+ + + } + /> + +
+
+ ) : null; +}; diff --git a/frontend/src/features/settings/components/dashboard/ManageSettingsDashboard.tsx b/frontend/src/features/settings/components/dashboard/ManageSettingsDashboard.tsx index a7d4ebcb0..bac22a9de 100644 --- a/frontend/src/features/settings/components/dashboard/ManageSettingsDashboard.tsx +++ b/frontend/src/features/settings/components/dashboard/ManageSettingsDashboard.tsx @@ -3,32 +3,40 @@ import { Navigate, useLocation } from "react-router-dom"; import { TabLayout } from "../../../../common/components/dashboard/TabLayout"; import { Suspend } from "../../pages/Suspend"; import { CreditAccount } from "../../pages/CreditAccount"; -import { SETTINGS_TABS } from "../../types/tabs"; -import { getDefaultRequiredVal } from "../../../../common/helpers/util"; +import { SETTINGS_TABS, SettingsTab } from "../../types/tabs"; import OnRouteBCContext from "../../../../common/authentication/OnRouteBCContext"; import { ERROR_ROUTES } from "../../../../routes/constants"; +import { SpecialAuthorizations } from "../../pages/SpecialAuthorizations/SpecialAuthorizations"; +import { useFeatureFlagsQuery } from "../../../../common/hooks/hooks"; import { + canViewSpecialAuthorizations, canViewSuspend, canViewCreditAccountTab, } from "../../helpers/permissions"; -import { useFeatureFlagsQuery } from "../../../../common/hooks/hooks"; export const ManageSettingsDashboard = React.memo(() => { - const { userRoles, companyId } = useContext(OnRouteBCContext); + const { + userRoles, + companyId, + idirUserDetails, + } = useContext(OnRouteBCContext); + const { data: featureFlags } = useFeatureFlagsQuery(); + const isStaffActingAsCompany = Boolean(idirUserDetails?.userAuthGroup); + const [hideSuspendTab, setHideSuspendTab] = useState(false); const showSuspendTab = canViewSuspend(userRoles) && !hideSuspendTab; + const showSpecialAuth = isStaffActingAsCompany && canViewSpecialAuthorizations( + userRoles, + idirUserDetails?.userAuthGroup, + ) && featureFlags?.["LOA"] === "ENABLED"; const showCreditAccountTab = canViewCreditAccountTab(userRoles) && featureFlags?.["CREDIT-ACCOUNT"] === "ENABLED"; const { state: stateFromNavigation } = useLocation(); - const selectedTab = getDefaultRequiredVal( - SETTINGS_TABS.SUSPEND, - stateFromNavigation?.selectedTab, - ); const handleHideSuspendTab = (hide: boolean) => { setHideSuspendTab(hide); @@ -38,27 +46,48 @@ export const ManageSettingsDashboard = React.memo(() => { return ; } - // Add more tabs here later when needed (eg. "Special Authorization", "Credit Account") + // Add more tabs here later when needed (eg. "Credit Account") const tabs = [ - showCreditAccountTab - ? { - label: "Credit Account", - component: , - } - : null, - showSuspendTab - ? { - label: "Suspend", - component: ( - - ), - } - : null, - ].filter((tab) => Boolean(tab)) as { + showSpecialAuth ? { + label: "Special Authorizations", + component: ( + + ), + componentKey: SETTINGS_TABS.SPECIAL_AUTH, + } : null, + showCreditAccountTab ? { + label: "Credit Account", + component: , + componentKey: SETTINGS_TABS.CREDIT_ACCOUNT, + } : null, + showSuspendTab ? { + label: "Suspend", + component: ( + + ), + componentKey: SETTINGS_TABS.SUSPEND, + } : null, + ].filter(tab => Boolean(tab)) as { label: string; component: JSX.Element; + componentKey: SettingsTab; }[]; + const getSelectedTabFromNavigation = (): number => { + const tabIndex = tabs.findIndex( + ({ componentKey }) => componentKey === stateFromNavigation?.selectedTab, + ); + if (tabIndex < 0) return 0; + return tabIndex; + }; + + const selectedTab = getSelectedTabFromNavigation(); + return ( , + userAuthGroup?: Nullable, +): boolean => { + const allowedAuthGroups = [ + USER_AUTH_GROUP.HQ_ADMINISTRATOR, + USER_AUTH_GROUP.FINANCE, + USER_AUTH_GROUP.SYSTEM_ADMINISTRATOR, + ] as UserAuthGroupType[]; + + return ( + userAuthGroup && allowedAuthGroups.includes(userAuthGroup) + ) || Boolean( + DoesUserHaveRole( + userRoles, + ROLES.WRITE_NOFEE, + ), + ); +}; + +export const canViewNoFeePermitsFlag = ( + userRoles?: Nullable, + userAuthGroup?: Nullable, +): boolean => { + return ( + userAuthGroup && READ_SPECIAL_AUTH_ALLOWED_GROUPS.includes(userAuthGroup) + ) || Boolean( + DoesUserHaveRole( + userRoles, + ROLES.READ_NOFEE, + ), + ) || canUpdateNoFeePermitsFlag(userRoles, userAuthGroup); +}; + +export const canUpdateLCVFlag = ( + userRoles?: Nullable, + userAuthGroup?: Nullable, +): boolean => { + return ( + userAuthGroup === USER_AUTH_GROUP.HQ_ADMINISTRATOR + ) || Boolean( + DoesUserHaveRole( + userRoles, + ROLES.WRITE_LCV_FLAG, + ), + ); +}; + +export const canViewLCVFlag = ( + userRoles?: Nullable, + userAuthGroup?: Nullable, +): boolean => { + return ( + userAuthGroup && READ_SPECIAL_AUTH_ALLOWED_GROUPS.includes(userAuthGroup) + ) || Boolean( + DoesUserHaveRole( + userRoles, + ROLES.READ_LCV_FLAG, + ), + ) || canUpdateLCVFlag(userRoles, userAuthGroup); +}; + +export const canUpdateLOA = ( + userRoles?: Nullable, + userAuthGroup?: Nullable, +): boolean => { + const allowedAuthGroups = [ + USER_AUTH_GROUP.HQ_ADMINISTRATOR, + USER_AUTH_GROUP.SYSTEM_ADMINISTRATOR, + ] as UserAuthGroupType[]; + + return ( + userAuthGroup && allowedAuthGroups.includes(userAuthGroup) + ) || Boolean( + DoesUserHaveRole( + userRoles, + ROLES.WRITE_LOA, + ), + ); +}; + +export const canViewLOA = ( + userRoles?: Nullable, + userAuthGroup?: Nullable, +): boolean => { + // Note that FIN is not allowed by view LOA + const allowedAuthGroups = [ + USER_AUTH_GROUP.PPC_CLERK, + USER_AUTH_GROUP.CTPO, + USER_AUTH_GROUP.ENFORCEMENT_OFFICER, + // USER_AUTH_GROUP.TRAINEE, + USER_AUTH_GROUP.HQ_ADMINISTRATOR, + USER_AUTH_GROUP.SYSTEM_ADMINISTRATOR, + USER_AUTH_GROUP.COMPANY_ADMINISTRATOR, + USER_AUTH_GROUP.PERMIT_APPLICANT, + ] as UserAuthGroupType[]; + + return ( + userAuthGroup && allowedAuthGroups.includes(userAuthGroup) + ) || Boolean( + DoesUserHaveRole( + userRoles, + ROLES.READ_LOA, + ), + ) || canUpdateLOA(userRoles, userAuthGroup); +}; + +export const canViewSpecialAuthorizations = ( + userRoles?: Nullable, + userAuthGroup?: Nullable, +): boolean => { + return ( + userAuthGroup && READ_SPECIAL_AUTH_ALLOWED_GROUPS.includes(userAuthGroup) + ) || canViewNoFeePermitsFlag(userRoles, userAuthGroup) + || canViewLCVFlag(userRoles, userAuthGroup) + || canViewLOA(userRoles, userAuthGroup) + || Boolean( + DoesUserHaveRole( + userRoles, + ROLES.READ_SPECIAL_AUTH, + ) + ); +}; + /** * Determine whether or not a user can view/access the settings tab given their roles. * @param userRoles Roles that a user have @@ -42,8 +177,11 @@ export const canUpdateSuspend = ( export const canViewSettingsTab = ( userRoles?: Nullable, ): boolean => { - // Need to update this check once Special Authorization and Credit Accounts tabs/features are added - return canViewSuspend(userRoles) || canUpdateSuspend(userRoles); + // Need to update this check once Credit Accounts tabs/features are added + return canViewSuspend(userRoles) + || canUpdateSuspend(userRoles) + || canViewSpecialAuthorizations(userRoles) + || canViewCreditAccountTab(userRoles); }; /** diff --git a/frontend/src/features/settings/hooks/LOA.ts b/frontend/src/features/settings/hooks/LOA.ts new file mode 100644 index 000000000..75749bde1 --- /dev/null +++ b/frontend/src/features/settings/hooks/LOA.ts @@ -0,0 +1,119 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { Nullable } from "../../../common/types/common"; +import { + createLOA, + getLOADetail, + getLOAs, + removeLOA, + removeLOADocument, + updateLOA, +} from "../apiManager/specialAuthorization"; + +const QUERY_KEYS = { + LOAS: (expired: boolean) => ["loas", expired], + LOA: (loaId?: Nullable) => ["loa", loaId], +}; + +/** + * Hook to fetch the LOAs for a company. + * @param companyId Company id of the company to fetch LOAs for + * @param expired Whether or not to only fetch expired LOAs + * @returns Query result of the company's LOAs + */ +export const useFetchLOAs = (companyId: number | string, expired: boolean) => { + return useQuery({ + queryKey: QUERY_KEYS.LOAS(expired), + queryFn: () => getLOAs(companyId, expired), + retry: false, + refetchOnMount: "always", + refetchOnWindowFocus: false, + }); +}; + +/** + * Hook to fetch the LOA details for a company's LOA. + * @param companyId Company id of the company to fetch LOA for + * @param loaId id of the LOA to fetch + * @returns Query result of the LOA details + */ +export const useFetchLOADetail = (companyId: number, loaId?: Nullable) => { + return useQuery({ + queryKey: QUERY_KEYS.LOA(loaId), + queryFn: () => { + if (!loaId) return undefined; + return getLOADetail(companyId, loaId); + }, + retry: false, + refetchOnMount: "always", + refetchOnWindowFocus: false, + enabled: Boolean(loaId), + }); +}; + +/** + * Hook to create an LOA for a company. + * @returns Result of creating the LOA + */ +export const useCreateLOAMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createLOA, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.LOAS(false), + }); + }, + }); +}; + +/** + * Hook to update an LOA for a company. + * @returns Result of updating the LOA + */ +export const useUpdateLOAMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateLOA, + onSuccess: (response) => { + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.LOAS(false), + }); + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.LOAS(true), + }); + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.LOA(response.data.loaId), + }); + }, + }); +}; + +/** + * Hook to remove an LOA for a company. + * @returns Result of removing the LOA + */ +export const useRemoveLOAMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: removeLOA, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.LOAS(false), + }); + }, + }); +}; + +/** + * Hook to remove the document for an LOA. + * @returns Result of removing the LOA document + */ +export const useRemoveLOADocumentMutation = () => { + return useMutation({ + mutationFn: removeLOADocument, + }); +}; diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/LOASteps.scss b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/LOASteps.scss new file mode 100644 index 000000000..8072df2d7 --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/LOASteps.scss @@ -0,0 +1,117 @@ +@use "../../../../../themes/orbcStyles"; + +.loa-steps { + padding-bottom: 3rem; + + & &__step-component { + margin-bottom: 2.5rem; + } + + .stepper { + margin: 2.5rem 0; + } + + .steps-navigation { + &__btn { + &--cancel { + margin-right: 2.5rem; + cursor: pointer; + background-color: orbcStyles.$bc-background-light-grey; + color: orbcStyles.$bc-black; + border: none; + + &:hover { + border: 2px solid orbcStyles.$bc-text-box-border-grey; + background-color: orbcStyles.$bc-background-light-grey; + cursor: pointer; + box-shadow: none; + } + } + + &--prev { + margin-right: 2.5rem; + border: 2px solid orbcStyles.$bc-primary-blue; + border-radius: 0.25rem; + color: orbcStyles.$bc-primary-blue; + background-color: orbcStyles.$white; + + &:hover { + color: orbcStyles.$white; + background-color: orbcStyles.$bc-text-links-blue; + border: 2px solid orbcStyles.$bc-text-links-blue; + } + } + + &--next, &--finish { + font-weight: bold; + } + } + } +} + +.step { + .loa-steps &#{&}--first { + padding-left: 0; + } + + .loa-steps & { + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + .loa-steps & &__label { + & { + align-items: flex-start; + } + + &-container { + text-align: start; + } + + &--active { + color: orbcStyles.$bc-background-blue; + font-weight: bold; + } + + &--completed { + color: orbcStyles.$bc-green; + font-weight: bold; + } + + &--disabled { + color: orbcStyles.$bc-text-box-border-grey; + font-weight: bold; + } + } + + .loa-steps & &__icon { + overflow: visible; + fill: orbcStyles.$white; + stroke-width: 0.25rem; + stroke: orbcStyles.$bc-text-box-border-grey; + + &--active { + stroke: orbcStyles.$bc-background-blue; + } + + &--completed { + stroke: orbcStyles.$white; + stroke-width: 0; + fill: orbcStyles.$bc-green; + transform: scale(1.2); + } + } + + .loa-steps & &__step-number { + display: none; + } + + .loa-steps & &__connector-line { + border-width: 0.25rem; + } + + .loa-steps & &__connector { + left: calc(-100% + 3.75rem); + right: calc(100% + 0.75rem); + } +} diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/LOASteps.tsx b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/LOASteps.tsx new file mode 100644 index 000000000..5825d4d8c --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/LOASteps.tsx @@ -0,0 +1,228 @@ +import { useEffect, useMemo, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons"; +import { Button, Step, StepConnector, StepLabel, Stepper } from "@mui/material"; +import { FormProvider, useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; + +import "./LOASteps.scss"; +import { LOAStep, LOA_STEPS, labelForLOAStep } from "../../../types/LOAStep"; +import { LOADesignateVehicles } from "./vehicles/LOADesignateVehicles"; +import { LOAReview } from "./review/LOAReview"; +import { LOABasicInfo } from "./basic/LOABasicInfo"; +import { Nullable } from "../../../../../common/types/common"; +import { LOAFormData, loaDetailToFormData } from "../../../types/LOAFormData"; +import { ERROR_ROUTES } from "../../../../../routes/constants"; +import { + useCreateLOAMutation, + useFetchLOADetail, + useRemoveLOADocumentMutation, + useUpdateLOAMutation, +} from "../../../hooks/LOA"; + +export const LOASteps = ({ + loaId, + companyId, + onExit, +}: { + loaId?: Nullable; + companyId: number; + onExit: () => void; +}) => { + const steps = [ + labelForLOAStep(LOA_STEPS.BASIC), + labelForLOAStep(LOA_STEPS.VEHICLES), + labelForLOAStep(LOA_STEPS.REVIEW), + ]; + + const navigate = useNavigate(); + const { data: loaDetail } = useFetchLOADetail(companyId, loaId); + const createLOAMutation = useCreateLOAMutation(); + const updateLOAMutation = useUpdateLOAMutation(); + const removeLOADocumentMutation = useRemoveLOADocumentMutation(); + const loaFormData = loaDetailToFormData(loaDetail); + + const formMethods = useForm({ + defaultValues: loaFormData, + reValidateMode: "onChange", + }); + + const { handleSubmit, reset, getValues } = formMethods; + + useEffect(() => { + reset(loaDetailToFormData(loaDetail)); + }, [loaDetail]); + + const [activeStep, setActiveStep] = useState(LOA_STEPS.BASIC); + + const showPrevBtn = activeStep === LOA_STEPS.VEHICLES || activeStep === LOA_STEPS.REVIEW; + const showNextBtn = activeStep === LOA_STEPS.BASIC || activeStep === LOA_STEPS.VEHICLES; + const showFinishBtn = activeStep === LOA_STEPS.REVIEW; + + const handlePrev = () => { + if (activeStep === LOA_STEPS.VEHICLES) { + setActiveStep(LOA_STEPS.BASIC); + } else if (activeStep === LOA_STEPS.REVIEW) { + setActiveStep(LOA_STEPS.VEHICLES); + } + }; + + const handleNext = () => { + if (activeStep === LOA_STEPS.BASIC) { + setActiveStep(LOA_STEPS.VEHICLES); + } else if (activeStep === LOA_STEPS.VEHICLES) { + setActiveStep(LOA_STEPS.REVIEW); + } + }; + + const handleFinish = async () => { + // Handle submitting LOA + const res = !loaId ? await createLOAMutation.mutateAsync({ + companyId, + data: getValues(), + }) : await updateLOAMutation.mutateAsync({ + companyId, + loaId, + data: getValues(), + }); + + if (res.status === 200 || res.status === 201) { + onExit(); + } else { + navigate(ERROR_ROUTES.UNEXPECTED); + } + }; + + const handleRemoveDocument = async () => { + if (!loaId) return true; // Free to remove newly loaded documents for not-yet created LOAs + + try { + const res = await removeLOADocumentMutation.mutateAsync({ + companyId, + loaId, + }); + + return res.status === 200; + } catch (e) { + console.error(e); + return false; + } + }; + + const stepComponent = useMemo(() => { + switch (activeStep) { + case LOA_STEPS.VEHICLES: + return ; + case LOA_STEPS.REVIEW: + return ; + default: + return ( + + ); + } + }, [activeStep]); + + return ( + +
+ + } + > + {steps.map((label, stepIndex) => ( + + {label} + + ))} + + +
+ {stepComponent} +
+ +
+ + + {showPrevBtn ? ( + + ) : null} + + {showNextBtn ? ( + + ) : null} + + {showFinishBtn ? ( + + ) : null} +
+
+
+ ); +}; diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/basic/LOABasicInfo.scss b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/basic/LOABasicInfo.scss new file mode 100644 index 000000000..9587d90fb --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/basic/LOABasicInfo.scss @@ -0,0 +1,115 @@ +@import "../../../../../../themes/orbcStyles"; + +.loa-basic-info { + color: $bc-black; + + &__header { + font-size: 1.25rem; + font-weight: bold; + } + + &__section { + &--permit-types { + border-bottom: 1px solid $bc-border-grey; + } + + &--dates, &--upload { + padding-top: 2.5rem; + padding-bottom: 2.5rem; + border-bottom: 1px solid $bc-border-grey; + } + + &--notes { + padding-top: 2.5rem; + } + } + + &__error { + color: $bc-red; + margin-top: 0.5rem; + } + + .permit-type-selection { + padding-top: 1.5rem; + display: grid; + grid-template-columns: repeat(4, [col] 1fr); + + &__category-header { + font-weight: bold; + grid-column: col / span 4; + } + + &__option { + display: flex; + flex-direction: row; + align-items: center; + grid-column: span 2; + padding-top: 1rem; + padding-bottom: 2.5rem; + } + + &__checkbox { + padding: 0; + margin-left: 0; + + &--invalid { + color: $bc-red; + } + } + + &__label { + margin: 0 0 0 0.5rem; + } + } + + &__date-selection { + margin-top: 1.5rem; + } + + .loa-date-inputs { + display: flex; + + &__start { + width: 16.125rem; + } + + &__expiry { + margin-left: 2.5rem; + width: 16.125rem; + } + } + + .loa-never-expires { + margin-top: 1.5rem; + display: flex; + align-items: center; + + &__checkbox { + margin-left: 0; + padding: 0; + } + + &__label { + margin: 0 0 0 0.5rem; + } + } + + .upload-input { + max-width: 34.75rem; + margin-top: 1.5rem; + } + + .uploaded-file { + margin-top: 1.5rem; + max-width: 34.75rem; + } + + &__notes { + margin-top: 1.5rem; + max-width: 34.75rem; + + .custom-form-control { + margin: 0; + } + } +} diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/basic/LOABasicInfo.tsx b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/basic/LOABasicInfo.tsx new file mode 100644 index 000000000..2e22a9660 --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/basic/LOABasicInfo.tsx @@ -0,0 +1,355 @@ +import { Dayjs } from "dayjs"; +import { Checkbox } from "@mui/material"; +import { + useFormContext, + FieldPathValue, + Controller, + FieldValues, +} from "react-hook-form"; + +import "./LOABasicInfo.scss"; +import { LOAFormData } from "../../../../types/LOAFormData"; +import { PERMIT_TYPES } from "../../../../../permits/types/PermitType"; +import { CustomFormComponent } from "../../../../../../common/components/form/CustomFormComponents"; +import { Nullable, Optional } from "../../../../../../common/types/common"; +import { UploadedFile } from "../../../../components/SpecialAuthorizations/LOA/upload/UploadedFile"; +import { UploadInput } from "../../../../components/SpecialAuthorizations/LOA/upload/UploadInput"; +import { applyWhenNotNullable } from "../../../../../../common/helpers/util"; +import { + expiryMustBeAfterStart, + invalidUploadFormat, + requiredMessage, + requiredUpload, + selectionRequired, + uploadSizeExceeded, +} from "../../../../../../common/helpers/validationMessages"; + +const FEATURE = "loa"; + +const permitTypeRules = { + validate: { + requiredPermitTypes: ( + value: Optional<{ + STOS: boolean; + TROS: boolean; + STOW: boolean; + TROW: boolean; + STOL: boolean; + STWS: boolean; + }>, + ) => { + return ( + value?.STOS || + value?.TROS || + value?.STOW || + value?.TROW || + value?.STOL || + value?.STWS || + selectionRequired() + ); + }, + }, +}; + +const expiryRules = { + validate: { + requiredIfLOAExpires: (value: Nullable, formValues: FieldValues) => { + return (!value && formValues.neverExpires) + || (Boolean(value) && !formValues.neverExpires) + || requiredMessage(); + }, + mustBeAfterStartDate: (value: Nullable, formValues: FieldValues) => { + return !value + || (value.isAfter(formValues.startDate)) + || expiryMustBeAfterStart(); + }, + }, +}; + +const uploadRules = { + validate: { + requiredLOAUpload: ( + value: Nullable<{ + fileName: string; + }> | File, + ) => { + return Boolean(value) || requiredUpload("LOA"); + }, + lessThanSizeLimit: ( + value: Nullable<{ + fileName: string; + }> | File, + ) => { + const fileSizeLimit = 10 * 1024 * 1024; + return !value + || !(value instanceof File) + || value.size < fileSizeLimit + || uploadSizeExceeded(); + }, + mustBePdf: ( + value: Nullable<{ + fileName: string; + }> | File, + ) => { + const fileFormat = "application/pdf"; + return !value + || !(value instanceof File) + || value.type === fileFormat + || invalidUploadFormat(); + }, + } +}; + +export const LOABasicInfo = ({ + onRemoveDocument, +}: { + onRemoveDocument: () => Promise; +}) => { + const { + control, + formState: { errors }, + watch, + setValue, + clearErrors, + trigger, + } = useFormContext(); + + const permitTypes = watch("permitTypes"); + const neverExpires = watch("neverExpires"); + const uploadFile = watch("uploadFile"); + + const fileExists = Boolean(uploadFile); + const fileName = applyWhenNotNullable( + file => (file instanceof File) ? file.name : file?.fileName, + uploadFile, + "", + ); + + const selectPermitType = ( + permitType: keyof FieldPathValue, + selected: boolean, + ) => { + setValue(`permitTypes.${permitType}`, selected); + if (Object.values(permitTypes).filter(selected => selected).length > 0) { + clearErrors("permitTypes"); + } + trigger("permitTypes"); + }; + + const toggleLOANeverExpires = (shouldNeverExpire: boolean) => { + setValue("neverExpires", shouldNeverExpire); + if (shouldNeverExpire) { + setValue("expiryDate", null); + } + trigger("expiryDate"); + }; + + const selectFile = (file: File) => { + setValue("uploadFile", file); + trigger("uploadFile"); + }; + + const deleteFile = async () => { + if (await onRemoveDocument()) { + setValue("uploadFile", null); + clearErrors("uploadFile"); + } + }; + + return ( +
+
+
+ Select Permit Type(s) +
+ + {errors.permitTypes ? ( +
+ {errors.permitTypes.message} +
+ ) : null} + + ( +
+
+ Oversize +
+ +
+ selectPermitType(PERMIT_TYPES.STOS, selected)} + /> +
{PERMIT_TYPES.STOS}
+
+ +
+ selectPermitType(PERMIT_TYPES.TROS, selected)} + /> +
{PERMIT_TYPES.TROS}
+
+ +
+ Overweight +
+ +
+ selectPermitType(PERMIT_TYPES.STOW, selected)} + /> +
{PERMIT_TYPES.STOW}
+
+ +
+ selectPermitType(PERMIT_TYPES.TROW, selected)} + /> +
{PERMIT_TYPES.TROW}
+
+ +
+ Overweight Oversized +
+ +
+ selectPermitType(PERMIT_TYPES.STOL, selected)} + /> +
+ {`${PERMIT_TYPES.STOL} (Length 27.5 - Empty)`} +
+
+ +
+ selectPermitType(PERMIT_TYPES.STWS, selected)} + /> +
{PERMIT_TYPES.STWS}
+
+
+ )} + /> +
+ +
+
+ Choose a Start Date and Expiry Date +
+ +
+
+ + + +
+ +
+ toggleLOANeverExpires(selected)} + /> +
LOA never expires
+
+
+
+ + ( +
+
+ Upload LOA +
+ + {fileExists ? ( + + ) : ( + + )} + + {error?.message ? ( +
+ {error.message} +
+ ) : null} +
+ )} + /> + +
+
+ Additional Notes +
+ + +
+
+ ); +}; diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/review/LOAReview.scss b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/review/LOAReview.scss new file mode 100644 index 000000000..3540c7135 --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/review/LOAReview.scss @@ -0,0 +1,21 @@ +@import "../../../../../../themes/orbcStyles"; + +.loa-review { + color: $bc-black; + + &__section { + margin-top: 1rem; + + &--permit-types { + margin-top: 0; + } + } + + &__header { + font-weight: bold; + } + + .loa-vehicle-table { + margin-top: 0.5rem; + } +} diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/review/LOAReview.tsx b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/review/LOAReview.tsx new file mode 100644 index 000000000..d57410a84 --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/review/LOAReview.tsx @@ -0,0 +1,100 @@ +import { useFormContext } from "react-hook-form"; + +import "./LOAReview.scss"; +import { LOAFormData } from "../../../../types/LOAFormData"; +import { DATE_FORMATS, dayjsToLocalStr } from "../../../../../../common/helpers/formatDate"; +import { applyWhenNotNullable } from "../../../../../../common/helpers/util"; +import { VEHICLE_TYPES } from "../../../../../manageVehicles/types/Vehicle"; +import { VehicleTable } from "../../../../components/SpecialAuthorizations/LOA/vehicles/VehicleTable"; +import { LOAVehicle } from "../../../../types/LOAVehicle"; + +export const LOAReview = () => { + const { getValues } = useFormContext(); + const formData = getValues(); + + const selectedPermitTypes = + Object.entries(formData.permitTypes) + .filter(permitTypeSelection => permitTypeSelection[1]) + .map(([permitType]) => permitType); + + const startDate = dayjsToLocalStr( + formData.startDate, + DATE_FORMATS.DATEONLY_SLASH, + ); + + const expiryDate = applyWhenNotNullable( + (expiry) => dayjsToLocalStr(expiry, DATE_FORMATS.DATEONLY_SLASH), + formData.expiryDate, + "LOA never expires", + ); + + const fileName = applyWhenNotNullable( + (file) => { + if (file instanceof File) return file.name; + return file.fileName; + }, + formData.uploadFile, + "" + ); + + const selectedVehicles = [ + ...Object.values(formData.selectedVehicles.powerUnits).map(powerUnit => ({ + ...powerUnit, + vehicleType: VEHICLE_TYPES.POWER_UNIT, + })), + ...Object.values(formData.selectedVehicles.trailers).map(trailer => ({ + ...trailer, + vehicleType: VEHICLE_TYPES.TRAILER, + })), + ]; + + return ( +
+
+
Permit Type(s)
+
+ {selectedPermitTypes.join(", ")} +
+
+ +
+
Start Date
+
+ {startDate} +
+
+ +
+
Expiry Date
+
+ {expiryDate} +
+
+ +
+
LOA
+
+ {fileName} +
+
+ + {formData.additionalNotes ? ( +
+
Additional Notes
+
+ {formData.additionalNotes} +
+
+ ) : null} + + {selectedVehicles.length > 0 ? ( +
+
Designated Vehicle(s)
+ +
+ ) : null} +
+ ); +}; diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/vehicles/LOADesignateVehicles.scss b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/vehicles/LOADesignateVehicles.scss new file mode 100644 index 000000000..249045297 --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/vehicles/LOADesignateVehicles.scss @@ -0,0 +1,22 @@ +@import "../../../../../../themes/orbcStyles"; + +.loa-designate-vehicles { + color: $bc-black; + + &__header { + font-weight: bold; + font-size: 1.25rem; + } + + &__info-banner { + margin: 1.5rem 0; + } + + &__info-message { + font-weight: normal; + } + + &__selection-error { + color: $bc-red; + } +} diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/vehicles/LOADesignateVehicles.tsx b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/vehicles/LOADesignateVehicles.tsx new file mode 100644 index 000000000..12b4b4787 --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/LOA/vehicles/LOADesignateVehicles.tsx @@ -0,0 +1,296 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import "./LOADesignateVehicles.scss"; +import { InfoBcGovBanner } from "../../../../../../common/components/banners/InfoBcGovBanner"; +import { BANNER_MESSAGES } from "../../../../../../common/constants/bannerMessages"; +import OnRouteBCContext from "../../../../../../common/authentication/OnRouteBCContext"; +import { applyWhenNotNullable, getDefaultRequiredVal } from "../../../../../../common/helpers/util"; +import { VEHICLE_TYPES } from "../../../../../manageVehicles/types/Vehicle"; +import { LOAFormData } from "../../../../types/LOAFormData"; +import { LOAVehicle } from "../../../../types/LOAVehicle"; +import { VehicleTable } from "../../../../components/SpecialAuthorizations/LOA/vehicles/VehicleTable"; +import { LOAVehicleTabLayout } from "../../../../components/SpecialAuthorizations/LOA/vehicles/LOAVehicleTabLayout"; +import { LOAVehicleTab, LOA_VEHICLE_TABS } from "../../../../types/LOAVehicleTab"; +import { selectionRequired } from "../../../../../../common/helpers/validationMessages"; +import { Nullable, Optional } from "../../../../../../common/types/common"; +import { + usePowerUnitSubTypesQuery, + usePowerUnitsQuery, +} from "../../../../../manageVehicles/hooks/powerUnits"; + +import { + useTrailerSubTypesQuery, + useTrailersQuery, +} from "../../../../../manageVehicles/hooks/trailers"; + +const vehicleSelectionRules = { + validate: { + requiredVehicleSelection: ( + value: Optional<{ + powerUnits: Record>; + trailers: Record>; + }>, + ) => { + return ( + applyWhenNotNullable( + selection => + Object.keys(selection.powerUnits).length > 0 + || Object.keys(selection.trailers).length > 0, + value, + false, + ) || + selectionRequired() + ); + }, + }, +}; + +const getVehicleDetailsForSelected = ( + selectedVehicles: string[], + vehicleSource: LOAVehicle[], +): Record => { + const selectedVehicleIds = new Set(selectedVehicles); + const detailsOfSelectedIds = [...selectedVehicleIds].map(vehicleId => { + const vehicleDetail = vehicleSource.find(vehicle => vehicle.vehicleId === vehicleId); + if (!vehicleDetail) return [vehicleId, null]; + return [vehicleId, { + vehicleId: vehicleDetail.vehicleId, + unitNumber: vehicleDetail.unitNumber, + make: vehicleDetail.make, + vehicleType: vehicleDetail.vehicleType, + vin: vehicleDetail.vin, + plate: vehicleDetail.plate, + vehicleSubType: vehicleDetail.vehicleSubType, + }]; + }); + + return Object.fromEntries(detailsOfSelectedIds); +}; + +export const LOADesignateVehicles = () => { + const { companyId } = useContext(OnRouteBCContext); + const companyIdStr = applyWhenNotNullable( + id => `${id}`, + companyId, + ); + + const [vehicleTab, setVehicleTab] = useState(LOA_VEHICLE_TABS.POWER_UNITS); + + const { + setValue, + getValues, + watch, + control, + formState: { errors }, + trigger, + } = useFormContext(); + + const selectedPowerUnits = watch("selectedVehicles.powerUnits"); + const selectedTrailers = watch("selectedVehicles.trailers"); + + const powerUnitsQuery = usePowerUnitsQuery(companyIdStr); + const trailersQuery = useTrailersQuery(companyIdStr); + const powerUnitSubTypesQuery = usePowerUnitSubTypesQuery(); + const trailerSubTypesQuery = useTrailerSubTypesQuery(); + const powerUnitSubTypes = useMemo(() => getDefaultRequiredVal( + [], + powerUnitSubTypesQuery.data, + ), [powerUnitSubTypesQuery.data]); + + const trailerSubTypes = useMemo(() => getDefaultRequiredVal( + [], + trailerSubTypesQuery.data, + ), [trailerSubTypesQuery.data]); + + const { data: powerUnitsData } = powerUnitsQuery; + const { data: trailersData } = trailersQuery; + + const powerUnits = useMemo(() => getDefaultRequiredVal([], powerUnitsData) + .map((vehicle) => ({ + vehicleId: vehicle.powerUnitId, + unitNumber: vehicle.unitNumber, + make: vehicle.make, + vin: vehicle.vin, + plate: vehicle.plate, + vehicleType: VEHICLE_TYPES.POWER_UNIT, + vehicleSubType: { + typeCode: vehicle.powerUnitTypeCode, + type: powerUnitSubTypes + .find(subType => subType.typeCode === vehicle.powerUnitTypeCode)?.type, + }, + })) as LOAVehicle[] + , [powerUnitsData]); + + const trailers = useMemo(() => getDefaultRequiredVal([], trailersData) + .map((vehicle) => ({ + vehicleId: vehicle.trailerId, + unitNumber: vehicle.unitNumber, + make: vehicle.make, + vin: vehicle.vin, + plate: vehicle.plate, + vehicleType: VEHICLE_TYPES.TRAILER, + vehicleSubType: { + typeCode: vehicle.trailerTypeCode, + type: trailerSubTypes + .find(subType => subType.typeCode === vehicle.trailerTypeCode)?.type, + }, + })) as LOAVehicle[] + , [trailersData]); + + useEffect(() => { + // If the fetched power units have ones that have been selected, fill the form data with vehicle information + const selectedPowerUnits = getValues("selectedVehicles.powerUnits"); + const detailsOfSelectedIds = getVehicleDetailsForSelected( + Object.keys(selectedPowerUnits), + powerUnits, + ); + + setValue( + "selectedVehicles.powerUnits", + detailsOfSelectedIds, + ); + }, [powerUnits, powerUnitSubTypes]); + + useEffect(() => { + // If the fetched trailers have ones that have been selected, fill the form data with vehicle information + const selectedTrailers = getValues("selectedVehicles.trailers"); + const detailsOfSelectedIds = getVehicleDetailsForSelected( + Object.keys(selectedTrailers), + trailers, + ); + + setValue( + "selectedVehicles.trailers", + detailsOfSelectedIds, + ); + }, [trailers, trailerSubTypes]); + + const selectionForPowerUnitTable = Object.fromEntries( + Object.entries(selectedPowerUnits) + .filter(selected => Boolean(selected[1])) + .map(([id]) => [`${VEHICLE_TYPES.POWER_UNIT}-${id}`, true]), + ); + + const selectionForTrailerTable = Object.fromEntries( + Object.entries(selectedTrailers) + .filter(selected => Boolean(selected[1])) + .map(([id]) => [`${VEHICLE_TYPES.TRAILER}-${id}`, true]), + ); + + const handlePowerUnitSelectionChange = ( + selection: { + [id: string]: boolean; + }, + ) => { + const updatedSelection = Object.entries(selection) + .filter((selectionRow) => selectionRow[1]) + .map(([id]) => { + // selection row ids are in the format "vehicleType-id" (eg. "powerUnit-1") + return id.split("-")[1]; // we only want the vehicleId part (ie. "1" in the example above) + }); + + const detailsOfSelectedIds = getVehicleDetailsForSelected(updatedSelection, powerUnits); + setValue( + "selectedVehicles.powerUnits", + detailsOfSelectedIds, + ); + trigger("selectedVehicles"); + }; + + const handleTrailerSelectionChange = ( + selection: { + [id: string]: boolean; + }, + ) => { + const updatedSelection = Object.entries(selection) + .filter((selectionRow) => selectionRow[1]) + .map(([id]) => { + // selection row ids are in the format "vehicleType-id" (eg. "trailer-1") + return id.split("-")[1]; // we only want the vehicleId part (ie. "1" in the example above) + }); + + const detailsOfSelectedIds = getVehicleDetailsForSelected(updatedSelection, trailers); + setValue( + "selectedVehicles.trailers", + detailsOfSelectedIds, + ); + trigger("selectedVehicles"); + }; + + const hasSelectionErrors = Boolean(errors.selectedVehicles?.message); + const tabComponents = [ + { + label: "Power Unit", + component: ( + + ), + }, + { + label: "Trailer", + component: ( + + ), + }, + ]; + + return ( +
+
+ Select Vehicle(s) for LOA +
+ + + {BANNER_MESSAGES.SELECT_VEHICLES_LOA_INFO} + + } + className="loa-designate-vehicles__info-banner" + /> + + ( +
+ setVehicleTab(tabIndex as LOAVehicleTab)} + /> + + {error?.message ? ( +
+ {error.message} +
+ ) : null} +
+ )} + /> +
+ ); +}; diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/SpecialAuthorizations.scss b/frontend/src/features/settings/pages/SpecialAuthorizations/SpecialAuthorizations.scss new file mode 100644 index 000000000..5dfc79b64 --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/SpecialAuthorizations.scss @@ -0,0 +1,41 @@ +@import "../../../../themes/orbcStyles"; + +.special-authorizations { + color: $bc-black; + + &__loa { + max-width: 59rem; + padding: 2.5rem 0; + + .add-loa-btn { + display: flex; + align-items: center; + + &__icon { + margin-right: 0.5rem; + } + } + } + + &__section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + } + + &__header-title { + font-weight: bold; + font-size: 1.25rem; + } +} + +.active-loas { + margin-top: 1.5rem; + + & &__header { + font-size: 1.25rem; + font-weight: bold; + padding: 1rem 0; + } +} diff --git a/frontend/src/features/settings/pages/SpecialAuthorizations/SpecialAuthorizations.tsx b/frontend/src/features/settings/pages/SpecialAuthorizations/SpecialAuthorizations.tsx new file mode 100644 index 000000000..9ebfbb5b6 --- /dev/null +++ b/frontend/src/features/settings/pages/SpecialAuthorizations/SpecialAuthorizations.tsx @@ -0,0 +1,276 @@ +import { useContext, useEffect, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { Button } from "@mui/material"; + +import "./SpecialAuthorizations.scss"; +import { RequiredOrNull } from "../../../../common/types/common"; +import { CustomActionLink } from "../../../../common/components/links/CustomActionLink"; +import { LOAList } from "../../components/SpecialAuthorizations/LOA/list/LOAList"; +import { ExpiredLOAModal } from "../../components/SpecialAuthorizations/LOA/expired/ExpiredLOAModal"; +import { DeleteConfirmationDialog } from "../../../../common/components/dialog/DeleteConfirmationDialog"; +import { LOASteps } from "./LOA/LOASteps"; +import { useFetchLOAs, useRemoveLOAMutation } from "../../hooks/LOA"; +import { getDefaultNullableVal, getDefaultRequiredVal } from "../../../../common/helpers/util"; +import { NoFeePermitType } from "../../types/SpecialAuthorization"; +import { NoFeePermitsSection } from "../../components/SpecialAuthorizations/NoFeePermits/NoFeePermitsSection"; +import OnRouteBCContext from "../../../../common/authentication/OnRouteBCContext"; +import { LCVSection } from "../../components/SpecialAuthorizations/LCV/LCVSection"; +import { downloadLOA } from "../../apiManager/specialAuthorization"; +import { + canUpdateLCVFlag, + canUpdateLOA, + canUpdateNoFeePermitsFlag, + canViewLCVFlag, + canViewLOA, + canViewNoFeePermitsFlag, +} from "../../helpers/permissions"; + +export const SpecialAuthorizations = ({ + companyId, +}: { + companyId: number; +}) => { + const [enableNoFeePermits, setEnableNoFeePermits] = useState(false); + const [noFeePermitType, setNoFeePermitType] + = useState>(null); + const [enableLCV, setEnableLCV] = useState(false); + const [showExpiredLOAs, setShowExpiredLOAs] = useState(false); + const [loaToDelete, setLoaToDelete] = useState>(null); + const [showLOASteps, setShowLOASteps] = useState(false); + const [loaToEdit, setLoaToEdit] = useState>(null); + + const { + userRoles, + idirUserDetails, + userDetails, + } = useContext(OnRouteBCContext); + + const canEditNoFeePermits = canUpdateNoFeePermitsFlag( + userRoles, + getDefaultNullableVal(idirUserDetails?.userAuthGroup, userDetails?.userAuthGroup), + ); + + const canViewNoFeePermits = canViewNoFeePermitsFlag( + userRoles, + getDefaultNullableVal(idirUserDetails?.userAuthGroup, userDetails?.userAuthGroup), + ); + + const canUpdateLCV = canUpdateLCVFlag( + userRoles, + getDefaultNullableVal(idirUserDetails?.userAuthGroup, userDetails?.userAuthGroup), + ); + + const canViewLCV = canViewLCVFlag( + userRoles, + getDefaultNullableVal(idirUserDetails?.userAuthGroup, userDetails?.userAuthGroup), + ); + + const canWriteLOA = canUpdateLOA( + userRoles, + getDefaultNullableVal(idirUserDetails?.userAuthGroup, userDetails?.userAuthGroup), + ); + + const canReadLOA = canViewLOA( + userRoles, + getDefaultNullableVal(idirUserDetails?.userAuthGroup, userDetails?.userAuthGroup), + ); + + const activeLOAsQuery = useFetchLOAs(companyId, false); + const expiredLOAsQuery = useFetchLOAs(companyId, true); + const removeLOAMutation = useRemoveLOAMutation(); + + const activeLOAs = getDefaultRequiredVal([], activeLOAsQuery.data); + const expiredLOAs = getDefaultRequiredVal([], expiredLOAsQuery.data); + + useEffect(() => { + if (!enableNoFeePermits) { + setNoFeePermitType(null); + } + }, [enableNoFeePermits]); + + const handleShowExpiredLOA = () => { + setShowExpiredLOAs(true); + }; + + const handleAddLOA = () => { + if (!canWriteLOA) return; + setShowLOASteps(true); + setLoaToEdit(null); + }; + + const handleEditLOA = (loaId: string) => { + if (!canWriteLOA) return; + setShowLOASteps(true); + setLoaToEdit(loaId); + }; + + const handleExitLOASteps = () => { + setShowLOASteps(false); + setLoaToEdit(null); + activeLOAsQuery.refetch(); + expiredLOAsQuery.refetch(); + }; + + const handleOpenDeleteModal = (loaId: string) => { + if (!canWriteLOA) return; + setLoaToDelete(loaId); + }; + + const handleCloseDeleteModal = () => { + setLoaToDelete(null); + }; + + const handleDeleteLOA = async (loaId: string) => { + try { + if (canWriteLOA) { + await removeLOAMutation.mutateAsync({ + companyId, + loaId, + }); + + activeLOAsQuery.refetch(); + } + } catch (e) { + console.error(e); + } finally { + setLoaToDelete(null); + } + }; + + const handleDownloadLOA = async (loaId: string) => { + if (loaId && canReadLOA) { + try { + const { blobObj: blobObjWithoutType } = await downloadLOA( + loaId, + companyId, + ); + + const objUrl = URL.createObjectURL( + new Blob([blobObjWithoutType], { type: "application/pdf" }), + ); + window.open(objUrl, "_blank"); + } catch (err) { + console.error(err); + } + } + }; + + const showExpiredLOAsLink = canReadLOA && (expiredLOAs.length > 0); + const showActiveLOAsList = canReadLOA && (activeLOAs.length > 0); + const showExpiredLOAsModal = canReadLOA && showExpiredLOAs; + const showDeleteDialog = canWriteLOA && loaToDelete; + + if (showLOASteps) { + return canWriteLOA ? ( + + ) : null; + } + + return ( +
+ {canViewNoFeePermits ? ( + + ) : null} + + {canViewLCV ? ( + + ) : null} + + {canReadLOA ? ( +
+
+
+ Letter of Authorization (LOA) +
+ + {showExpiredLOAsLink ? ( + + Expired LOA(s) + + ) : null} +
+ + {canWriteLOA ? ( + + ) : ( +
+ Download the letter to see the specific travel terms of the LOA. +
+ )} + + {showActiveLOAsList ? ( +
+
+ Active LOA(s) +
+ + +
+ ) : null} +
+ ) : null} + + {showExpiredLOAsModal ? ( + setShowExpiredLOAs(false)} + expiredLOAs={expiredLOAs} + handleEdit={(loaId) => { + setShowExpiredLOAs(false); + handleEditLOA(loaId); + }} + handleDownload={handleDownloadLOA} + /> + ) : null} + + {showDeleteDialog ? ( + handleDeleteLOA(loaToDelete)} + itemToDelete="item" + confirmationMsg={"Are you sure you want to delete this?"} + /> + ) : null} +
+ ); +}; diff --git a/frontend/src/features/settings/types/LOAFormData.ts b/frontend/src/features/settings/types/LOAFormData.ts new file mode 100644 index 000000000..5d5d343a1 --- /dev/null +++ b/frontend/src/features/settings/types/LOAFormData.ts @@ -0,0 +1,137 @@ +import { Dayjs } from "dayjs"; + +import { Nullable } from "../../../common/types/common"; +import { PERMIT_TYPES } from "../../permits/types/PermitType"; +import { LOAVehicle } from "./LOAVehicle"; +import { LOADetail } from "./SpecialAuthorization"; +import { + applyWhenNotNullable, + getDefaultRequiredVal, +} from "../../../common/helpers/util"; + +import { + DATE_FORMATS, + dayjsToLocalStr, + getEndOfDate, + getStartOfDate, + now, + toLocalDayjs, +} from "../../../common/helpers/formatDate"; + +export interface LOAFormData { + permitTypes: { + [PERMIT_TYPES.STOS]: boolean; + [PERMIT_TYPES.TROS]: boolean; + [PERMIT_TYPES.STOW]: boolean; + [PERMIT_TYPES.TROW]: boolean; + [PERMIT_TYPES.STOL]: boolean; + [PERMIT_TYPES.STWS]: boolean; + }; + startDate: Dayjs; + expiryDate?: Nullable; + neverExpires: boolean; + uploadFile: Nullable<{ + fileName: string; + }> | File; + additionalNotes?: Nullable; + selectedVehicles: { + powerUnits: Record>; + trailers: Record>; + }; +} + +/** + * Transform LOA detail response object to form data. + * @param loaDetail LOA detail object received as response + * @returns Form data values for the LOA + */ +export const loaDetailToFormData = ( + loaDetail?: Nullable, +): LOAFormData => { + const loaDetailPermitTypes = getDefaultRequiredVal([], loaDetail?.loaPermitType); + const permitTypes = { + [PERMIT_TYPES.STOS]: loaDetailPermitTypes.includes(PERMIT_TYPES.STOS), + [PERMIT_TYPES.TROS]: loaDetailPermitTypes.includes(PERMIT_TYPES.TROS), + [PERMIT_TYPES.STOW]: loaDetailPermitTypes.includes(PERMIT_TYPES.STOW), + [PERMIT_TYPES.TROW]: loaDetailPermitTypes.includes(PERMIT_TYPES.TROW), + [PERMIT_TYPES.STOL]: loaDetailPermitTypes.includes(PERMIT_TYPES.STOL), + [PERMIT_TYPES.STWS]: loaDetailPermitTypes.includes(PERMIT_TYPES.STWS), + }; + + const startDate = applyWhenNotNullable( + startDateStr => getStartOfDate(toLocalDayjs(startDateStr)), + loaDetail?.startDate, + now(), + ); + const expiryDate = applyWhenNotNullable( + expiryDateStr => getEndOfDate(toLocalDayjs(expiryDateStr)), + loaDetail?.expiryDate, + null, + ); + const neverExpires = !expiryDate; + const additionalNotes = getDefaultRequiredVal("", loaDetail?.comment); + const powerUnits = applyWhenNotNullable( + powerUnitsArr => Object.fromEntries(powerUnitsArr.map(powerUnitId => [powerUnitId, null])), + loaDetail?.powerUnits, + {}, + ); + const trailers = applyWhenNotNullable( + trailersArr => Object.fromEntries(trailersArr.map(trailerId => [trailerId, null])), + loaDetail?.trailers, + {}, + ); + const defaultFile = loaDetail?.documentId ? { + fileName: getDefaultRequiredVal("", loaDetail?.fileName), + } : null; + + return { + permitTypes, + startDate, + expiryDate, + neverExpires, + uploadFile: defaultFile, + additionalNotes, + selectedVehicles: { + powerUnits, + trailers, + }, + }; +}; + +/** + * Serialize LOA form data for create or update request payloads. + * @param loaFormData Populated form data for the LOA + * @returns Serialized request payload for creating or updating an LOA + */ +export const serializeLOAFormData = (loaFormData: LOAFormData) => { + const requestData = new FormData(); + + const permitTypes = Object.entries(loaFormData.permitTypes) + .filter(([, selected]) => { + return selected; + }) + .map(([permitType]) => permitType); + + const powerUnits = Object.keys(loaFormData.selectedVehicles.powerUnits); + const trailers = Object.keys(loaFormData.selectedVehicles.trailers); + + const body = { + startDate: dayjsToLocalStr(loaFormData.startDate, DATE_FORMATS.DATEONLY), + expiryDate: loaFormData.expiryDate + ? dayjsToLocalStr(loaFormData.expiryDate, DATE_FORMATS.DATEONLY) + : null, + comment: getDefaultRequiredVal("", loaFormData.additionalNotes), + loaPermitType: permitTypes, + powerUnits: powerUnits.length > 0 ? powerUnits : undefined, + trailers: trailers.length > 0 ? trailers : undefined, + }; + + if (loaFormData.uploadFile instanceof File) { + // is newly uploaded file + requestData.append("file", loaFormData.uploadFile); + } + + requestData.append("body", JSON.stringify(body)); + + return requestData; +}; diff --git a/frontend/src/features/settings/types/LOAStep.ts b/frontend/src/features/settings/types/LOAStep.ts new file mode 100644 index 000000000..8235fbe4a --- /dev/null +++ b/frontend/src/features/settings/types/LOAStep.ts @@ -0,0 +1,18 @@ +export const LOA_STEPS = { + BASIC: 0, + VEHICLES: 1, + REVIEW: 2, +} as const; + +export type LOAStep = typeof LOA_STEPS[keyof typeof LOA_STEPS]; + +export const labelForLOAStep = (loaStep: LOAStep) => { + switch (loaStep) { + case LOA_STEPS.REVIEW: + return "Review and Confirm Details"; + case LOA_STEPS.VEHICLES: + return "Designate Vehicle(s)"; + default: + return "Basic Information"; + } +}; diff --git a/frontend/src/features/settings/types/LOAVehicle.ts b/frontend/src/features/settings/types/LOAVehicle.ts new file mode 100644 index 000000000..fcfd5bf26 --- /dev/null +++ b/frontend/src/features/settings/types/LOAVehicle.ts @@ -0,0 +1,15 @@ +import { Nullable } from "../../../common/types/common"; +import { VehicleType } from "../../manageVehicles/types/Vehicle"; + +export interface LOAVehicle { + vehicleId: string; + unitNumber?: Nullable; + make: string; + vin: string; + plate: string; + vehicleType: VehicleType; + vehicleSubType: { + typeCode: string; + type?: Nullable; + }; +}; diff --git a/frontend/src/features/settings/types/LOAVehicleTab.ts b/frontend/src/features/settings/types/LOAVehicleTab.ts new file mode 100644 index 000000000..57c812418 --- /dev/null +++ b/frontend/src/features/settings/types/LOAVehicleTab.ts @@ -0,0 +1,6 @@ +export const LOA_VEHICLE_TABS = { + POWER_UNITS: 0, + TRAILERS: 1, +} as const; + +export type LOAVehicleTab = typeof LOA_VEHICLE_TABS[keyof typeof LOA_VEHICLE_TABS]; diff --git a/frontend/src/features/settings/types/SpecialAuthorization.ts b/frontend/src/features/settings/types/SpecialAuthorization.ts new file mode 100644 index 000000000..eecb3dce3 --- /dev/null +++ b/frontend/src/features/settings/types/SpecialAuthorization.ts @@ -0,0 +1,61 @@ +import { Nullable } from "../../../common/types/common"; +import { PermitType } from "../../permits/types/PermitType"; + +export const NO_FEE_PERMIT_TYPES = { + GOV_PROV_CAN: 1, + MUNICIPAL_CAN: 2, + DISTRICT_OUT_BC: 3, + GOV_USA: 4, + GOV_PROV_USA: 5, +} as const; + +export type NoFeePermitType = typeof NO_FEE_PERMIT_TYPES[keyof typeof NO_FEE_PERMIT_TYPES]; + +export const noFeePermitTypeDescription = (noFeePermitType: NoFeePermitType) => { + switch (noFeePermitType) { + case NO_FEE_PERMIT_TYPES.GOV_PROV_CAN: + return "The government of Canada or any province or territory"; + case NO_FEE_PERMIT_TYPES.MUNICIPAL_CAN: + return "A municipality"; + case NO_FEE_PERMIT_TYPES.DISTRICT_OUT_BC: + return "A school district outside of BC (S. 9 Commercial Transport Act)"; + case NO_FEE_PERMIT_TYPES.GOV_USA: + return "The government of the United States of America"; + default: + return "The government of any state or county in the United States of America"; + } +}; + +export interface LOADetail { + loaId: string; + loaNumber: string; + companyId: number; + startDate: string; + expiryDate?: Nullable; + documentId: string; + fileName: string; + loaPermitType: PermitType[]; + comment?: Nullable; + powerUnits: string[]; + trailers: string[]; +} + +export interface CreateLOARequestData { + startDate: string; + expiryDate?: Nullable; + loaPermitType: PermitType[]; + // document: Buffer; + comment?: Nullable; + powerUnits: string[]; + trailers: string[]; +} + +export interface UpdateLOARequestData { + startDate: string; + expiryDate?: Nullable; + loaPermitType: PermitType[]; + // document?: Buffer; + comment?: Nullable; + powerUnits: string[]; + trailers: string[]; +} diff --git a/frontend/src/features/settings/types/tabs.ts b/frontend/src/features/settings/types/tabs.ts index 990b04d01..befd76b6d 100644 --- a/frontend/src/features/settings/types/tabs.ts +++ b/frontend/src/features/settings/types/tabs.ts @@ -3,5 +3,9 @@ * Index starts at 0. */ export const SETTINGS_TABS = { - SUSPEND: 0, + SPECIAL_AUTH: 0, + CREDIT_ACCOUNT: 1, + SUSPEND: 2, } as const; + +export type SettingsTab = typeof SETTINGS_TABS[keyof typeof SETTINGS_TABS]; diff --git a/frontend/src/features/wizard/UserInfoWizard.scss b/frontend/src/features/wizard/UserInfoWizard.scss new file mode 100644 index 000000000..215ebf8ae --- /dev/null +++ b/frontend/src/features/wizard/UserInfoWizard.scss @@ -0,0 +1,3 @@ +@use "../../common/components/dashboard/Dashboard"; + +@include Dashboard.page-tabpanel-container-style(".user-info-wizard"); diff --git a/frontend/src/features/wizard/UserInfoWizard.tsx b/frontend/src/features/wizard/UserInfoWizard.tsx index d1569ecff..8232e5810 100644 --- a/frontend/src/features/wizard/UserInfoWizard.tsx +++ b/frontend/src/features/wizard/UserInfoWizard.tsx @@ -4,6 +4,7 @@ import React, { useContext, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { FieldValues, FormProvider, useForm } from "react-hook-form"; +import "./UserInfoWizard.scss"; import { SnackBarContext } from "../../App"; import { LoadBCeIDUserContext } from "../../common/authentication/LoadBCeIDUserContext"; import { LoadBCeIDUserRolesByCompany } from "../../common/authentication/LoadBCeIDUserRolesByCompany"; @@ -94,7 +95,7 @@ export const UserInfoWizard = React.memo(() => {
diff --git a/frontend/src/features/wizard/components/dashboard/ChallengeProfileSteps.tsx b/frontend/src/features/wizard/components/dashboard/ChallengeProfileSteps.tsx index b8bcac906..d4ec15303 100644 --- a/frontend/src/features/wizard/components/dashboard/ChallengeProfileSteps.tsx +++ b/frontend/src/features/wizard/components/dashboard/ChallengeProfileSteps.tsx @@ -9,7 +9,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "react-router"; import { LoadBCeIDUserRolesByCompany } from "../../../../common/authentication/LoadBCeIDUserRolesByCompany"; import { Banner } from "../../../../common/components/dashboard/components/banner/Banner"; -import "../../../../common/components/dashboard/Dashboard.scss"; import { getDefaultRequiredVal } from "../../../../common/helpers/util"; import { Nullable } from "../../../../common/types/common"; import { ERROR_ROUTES } from "../../../../routes/constants"; @@ -223,7 +222,7 @@ export const ChallengeProfileSteps = React.memo(() => {
{ />
diff --git a/frontend/src/routes/Routes.tsx b/frontend/src/routes/Routes.tsx index 929772806..33087b72b 100644 --- a/frontend/src/routes/Routes.tsx +++ b/frontend/src/routes/Routes.tsx @@ -313,6 +313,9 @@ export const AppRoutes = () => { IDIR_USER_AUTH_GROUP.FINANCE, IDIR_USER_AUTH_GROUP.PPC_CLERK, IDIR_USER_AUTH_GROUP.CTPO, + IDIR_USER_AUTH_GROUP.HQ_ADMINISTRATOR, + IDIR_USER_AUTH_GROUP.ENFORCEMENT_OFFICER, + // IDIR_USER_AUTH_GROUP.TRAINEE, ]} /> } diff --git a/frontend/src/themes/orbcStyles.scss b/frontend/src/themes/orbcStyles.scss index e1e13e9d9..778dd93e7 100644 --- a/frontend/src/themes/orbcStyles.scss +++ b/frontend/src/themes/orbcStyles.scss @@ -10,6 +10,7 @@ $banner-grey: #ebeef3; $bc-background-blue: #38598a; $bc-background-blue-grey: #f4f5f8; $bc-background-light-grey: #f2f2f2; +$bc-background-secondary-grey: #e6e6e6; $bc-black: #313132; $bc-border-grey: #dbdcdc; $bc-brown: #6c4a00; diff --git a/vehicles/src/modules/loa/profile/loa.profile.ts b/vehicles/src/modules/loa/profile/loa.profile.ts index 9524e18b7..4f29457ec 100644 --- a/vehicles/src/modules/loa/profile/loa.profile.ts +++ b/vehicles/src/modules/loa/profile/loa.profile.ts @@ -120,6 +120,7 @@ export class LoaProfile extends AutomapperProfile { loaPermitType.updatedUser = userName; loaPermitType.updatedUserDirectory = directory; loaPermitType.updatedUserGuid = userGUID; + loaPermitTypes.push(loaPermitType); } return loaPermitTypes; },