diff --git a/pkg/gen/ghcapi/embedded_spec.go b/pkg/gen/ghcapi/embedded_spec.go index 740d92c587f..842883ee673 100644 --- a/pkg/gen/ghcapi/embedded_spec.go +++ b/pkg/gen/ghcapi/embedded_spec.go @@ -1993,7 +1993,8 @@ func init() { "APPROVALS REQUESTED", "APPROVED", "NEEDS SERVICE COUNSELING", - "SERVICE COUNSELING COMPLETED" + "SERVICE COUNSELING COMPLETED", + "CANCELED" ] } } @@ -2351,7 +2352,7 @@ func init() { "operationId": "moveCanceler", "responses": { "200": { - "description": "Successfully cancelled move", + "description": "Successfully canceled move", "schema": { "$ref": "#/definitions/Move" } @@ -2374,7 +2375,10 @@ func init() { "500": { "$ref": "#/responses/ServerError" } - } + }, + "x-permissions": [ + "update.cancelMoveFlag" + ] }, "parameters": [ { @@ -11286,7 +11290,7 @@ func init() { "PPMStatus": { "type": "string", "enum": [ - "CANCELLED", + "CANCELED", "DRAFT", "SUBMITTED", "WAITING_ON_CUSTOMER", @@ -16763,7 +16767,8 @@ func init() { "APPROVALS REQUESTED", "APPROVED", "NEEDS SERVICE COUNSELING", - "SERVICE COUNSELING COMPLETED" + "SERVICE COUNSELING COMPLETED", + "CANCELED" ] } } @@ -17202,7 +17207,7 @@ func init() { "operationId": "moveCanceler", "responses": { "200": { - "description": "Successfully cancelled move", + "description": "Successfully canceled move", "schema": { "$ref": "#/definitions/Move" } @@ -17243,7 +17248,10 @@ func init() { "$ref": "#/definitions/Error" } } - } + }, + "x-permissions": [ + "update.cancelMoveFlag" + ] }, "parameters": [ { @@ -27091,7 +27099,7 @@ func init() { "PPMStatus": { "type": "string", "enum": [ - "CANCELLED", + "CANCELED", "DRAFT", "SUBMITTED", "WAITING_ON_CUSTOMER", diff --git a/pkg/gen/ghcapi/ghcoperations/move/move_canceler_responses.go b/pkg/gen/ghcapi/ghcoperations/move/move_canceler_responses.go index 828a8086069..d96b78fc5af 100644 --- a/pkg/gen/ghcapi/ghcoperations/move/move_canceler_responses.go +++ b/pkg/gen/ghcapi/ghcoperations/move/move_canceler_responses.go @@ -17,7 +17,7 @@ import ( const MoveCancelerOKCode int = 200 /* -MoveCancelerOK Successfully cancelled move +MoveCancelerOK Successfully canceled move swagger:response moveCancelerOK */ diff --git a/pkg/gen/ghcapi/ghcoperations/move/search_moves.go b/pkg/gen/ghcapi/ghcoperations/move/search_moves.go index 9ca46adcc1f..f22f0fbf7cf 100644 --- a/pkg/gen/ghcapi/ghcoperations/move/search_moves.go +++ b/pkg/gen/ghcapi/ghcoperations/move/search_moves.go @@ -370,7 +370,7 @@ var searchMovesBodyStatusItemsEnum []interface{} func init() { var res []string - if err := json.Unmarshal([]byte(`["DRAFT","SUBMITTED","APPROVALS REQUESTED","APPROVED","NEEDS SERVICE COUNSELING","SERVICE COUNSELING COMPLETED"]`), &res); err != nil { + if err := json.Unmarshal([]byte(`["DRAFT","SUBMITTED","APPROVALS REQUESTED","APPROVED","NEEDS SERVICE COUNSELING","SERVICE COUNSELING COMPLETED","CANCELED"]`), &res); err != nil { panic(err) } for _, v := range res { diff --git a/pkg/gen/ghcmessages/p_p_m_status.go b/pkg/gen/ghcmessages/p_p_m_status.go index a581261623a..720b370a8d3 100644 --- a/pkg/gen/ghcmessages/p_p_m_status.go +++ b/pkg/gen/ghcmessages/p_p_m_status.go @@ -30,8 +30,8 @@ func (m PPMStatus) Pointer() *PPMStatus { const ( - // PPMStatusCANCELLED captures enum value "CANCELLED" - PPMStatusCANCELLED PPMStatus = "CANCELLED" + // PPMStatusCANCELED captures enum value "CANCELED" + PPMStatusCANCELED PPMStatus = "CANCELED" // PPMStatusDRAFT captures enum value "DRAFT" PPMStatusDRAFT PPMStatus = "DRAFT" @@ -60,7 +60,7 @@ var pPMStatusEnum []interface{} func init() { var res []PPMStatus - if err := json.Unmarshal([]byte(`["CANCELLED","DRAFT","SUBMITTED","WAITING_ON_CUSTOMER","NEEDS_ADVANCE_APPROVAL","NEEDS_CLOSEOUT","CLOSEOUT_COMPLETE","COMPLETED"]`), &res); err != nil { + if err := json.Unmarshal([]byte(`["CANCELED","DRAFT","SUBMITTED","WAITING_ON_CUSTOMER","NEEDS_ADVANCE_APPROVAL","NEEDS_CLOSEOUT","CLOSEOUT_COMPLETE","COMPLETED"]`), &res); err != nil { panic(err) } for _, v := range res { diff --git a/pkg/handlers/authentication/permissions.go b/pkg/handlers/authentication/permissions.go index 75efbb75cd0..ca2c9045bcb 100644 --- a/pkg/handlers/authentication/permissions.go +++ b/pkg/handlers/authentication/permissions.go @@ -40,6 +40,7 @@ var TOO = RolePermissions{ "update.closeoutOffice", "update.MTOPage", "create.TXOShipment", + "update.cancelMoveFlag", }, } @@ -86,6 +87,7 @@ var ServicesCounselor = RolePermissions{ "update.customer", "update.closeoutOffice", "view.closeoutOffice", + "update.cancelMoveFlag", }, } diff --git a/pkg/services/move/move_canceler.go b/pkg/services/move/move_canceler.go index 26a0c2e04bb..64ec885b93d 100644 --- a/pkg/services/move/move_canceler.go +++ b/pkg/services/move/move_canceler.go @@ -39,7 +39,7 @@ func (f *moveCanceler) CancelMove(appCtx appcontext.AppContext, moveID uuid.UUID if shipment.PPMShipment != nil { if shipment.PPMShipment.Status == models.PPMShipmentStatusCloseoutComplete { - return apperror.NewConflictError(move.ID, " cannot cancel move with approved shipment.") + return apperror.NewConflictError(move.ID, " cannot cancel move with a closeout complete shipment.") } var ppmshipment models.PPMShipment qerr := appCtx.DB().Where("id = ?", shipment.PPMShipment.ID).First(&ppmshipment) @@ -55,9 +55,7 @@ func (f *moveCanceler) CancelMove(appCtx appcontext.AppContext, moveID uuid.UUID } else if err != nil { return apperror.NewQueryError("PPM Shipment", err, "Failed to update status for ppm shipment") } - } - - if shipment.Status != models.MTOShipmentStatusApproved { + } else if shipment.Status != models.MTOShipmentStatusApproved { verrs, err := txnAppCtx.DB().ValidateAndUpdate(&shipmentDelta) if verrs != nil && verrs.HasAny() { return apperror.NewInvalidInputError(shipment.ID, err, verrs, "Validation errors found while setting shipment status") diff --git a/pkg/services/move/move_canceler_test.go b/pkg/services/move/move_canceler_test.go index 314b0719075..0cad20c55eb 100644 --- a/pkg/services/move/move_canceler_test.go +++ b/pkg/services/move/move_canceler_test.go @@ -30,4 +30,23 @@ func (suite *MoveServiceSuite) TestMoveCanceler() { _, err := moveCanceler.CancelMove(suite.AppContextForTest(), move.ID) suite.Error(err) }) + + suite.Run("fails to cancel move with close complete ppm shipment", func() { + move := factory.BuildMove(suite.DB(), nil, nil) + + factory.BuildPPMShipment(suite.DB(), []factory.Customization{ + { + Model: move, + LinkOnly: true, + }, + { + Model: models.PPMShipment{ + Status: models.PPMShipmentStatusCloseoutComplete, + }, + }, + }, nil) + + _, err := moveCanceler.CancelMove(suite.AppContextForTest(), move.ID) + suite.Error(err) + }) } diff --git a/src/components/ConfirmationModals/CancelMoveConfirmationModal.jsx b/src/components/ConfirmationModals/CancelMoveConfirmationModal.jsx new file mode 100644 index 00000000000..3a705d1ab6a --- /dev/null +++ b/src/components/ConfirmationModals/CancelMoveConfirmationModal.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button } from '@trussworks/react-uswds'; + +import Modal, { ModalTitle, ModalClose, ModalActions, connectModal } from 'components/Modal/Modal'; + +export const CancelMoveConfirmationModal = ({ onClose, onSubmit, moveID, title, content, submitText, closeText }) => ( + + onClose()} /> + +

{title}

+
+

{content}

+ + + + +
+); + +CancelMoveConfirmationModal.propTypes = { + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + + title: PropTypes.string, + content: PropTypes.string, + submitText: PropTypes.string, + closeText: PropTypes.string, +}; + +CancelMoveConfirmationModal.defaultProps = { + title: 'Are you sure?', + content: + 'You’ll lose all the information in this move. If you want it back later, you’ll have to request a new move.', + submitText: 'Cancel move', + closeText: 'Keep move', +}; + +CancelMoveConfirmationModal.displayName = 'CancelMoveConfirmationModal'; + +export default connectModal(CancelMoveConfirmationModal); diff --git a/src/components/ConfirmationModals/CancelMoveConfirmationModal.stories.jsx b/src/components/ConfirmationModals/CancelMoveConfirmationModal.stories.jsx new file mode 100644 index 00000000000..15205131faa --- /dev/null +++ b/src/components/ConfirmationModals/CancelMoveConfirmationModal.stories.jsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import CancelMoveConfirmationModal from './CancelMoveConfirmationModal'; + +export default { + title: 'Components/CancelMoveConfirmationModal', + component: CancelMoveConfirmationModal, + args: { + moveID: '111', + }, + argTypes: { + onClose: { action: 'close button clicked' }, + onSubmit: { action: 'submit button clicked' }, + }, +}; + +const Template = (args) => ; + +export const Basic = Template.bind({}); + +export const WithOverrides = Template.bind({}); +WithOverrides.args = { + title: 'This is a sample title', + content: 'Some sample description', + submitText: 'YES!', + closeText: 'NO', +}; + +const ConnectedTemplate = (args) => ; +export const ConnectedModal = ConnectedTemplate.bind({}); +ConnectedModal.args = { + isOpen: true, +}; diff --git a/src/components/ConfirmationModals/CancelMoveConfirmationModal.test.jsx b/src/components/ConfirmationModals/CancelMoveConfirmationModal.test.jsx new file mode 100644 index 00000000000..76c5b4b885c --- /dev/null +++ b/src/components/ConfirmationModals/CancelMoveConfirmationModal.test.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CancelMoveConfirmationModal } from 'components/ConfirmationModals/CancelMoveConfirmationModal'; + +let onClose; +let onSubmit; +beforeEach(() => { + onClose = jest.fn(); + onSubmit = jest.fn(); +}); + +describe('CancelMoveConfirmationModal', () => { + const moveID = '123456'; + + it('renders the component', async () => { + render(); + + expect(await screen.findByRole('heading', { level: 3, name: 'Are you sure?' })).toBeInTheDocument(); + }); + + it('closes the modal when close icon is clicked', async () => { + render(); + + const closeButton = await screen.findByTestId('modalCloseButton'); + + await userEvent.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('closes the modal when the keep button is clicked', async () => { + render(); + + const keepButton = await screen.findByRole('button', { name: 'Keep move' }); + + await userEvent.click(keepButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls the submit function when cancel button is clicked', async () => { + render(); + + const cancelButton = await screen.findByRole('button', { name: 'Cancel move' }); + + await userEvent.click(cancelButton); + + expect(onSubmit).toHaveBeenCalledWith(moveID); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Office/EvaluationReportShipmentDisplay/EvaluationReportShipmentDisplay.jsx b/src/components/Office/EvaluationReportShipmentDisplay/EvaluationReportShipmentDisplay.jsx index b13f4b0f6c6..e562a3d8521 100644 --- a/src/components/Office/EvaluationReportShipmentDisplay/EvaluationReportShipmentDisplay.jsx +++ b/src/components/Office/EvaluationReportShipmentDisplay/EvaluationReportShipmentDisplay.jsx @@ -54,7 +54,7 @@ const EvaluationReportShipmentDisplay = ({ {displayInfo.isDiversion && diversion} - {displayInfo.shipmentStatus === shipmentStatuses.CANCELED && cancelled} + {displayInfo.shipmentStatus === shipmentStatuses.CANCELED && canceled} {displayInfo.shipmentStatus === shipmentStatuses.DIVERSION_REQUESTED && diversion requested} {displayInfo.shipmentStatus === shipmentStatuses.CANCELLATION_REQUESTED && ( cancellation requested diff --git a/src/components/Office/ShipmentDisplay/ShipmentDisplay.jsx b/src/components/Office/ShipmentDisplay/ShipmentDisplay.jsx index d4827aefa74..c04154c1c8e 100644 --- a/src/components/Office/ShipmentDisplay/ShipmentDisplay.jsx +++ b/src/components/Office/ShipmentDisplay/ShipmentDisplay.jsx @@ -94,8 +94,10 @@ const ShipmentDisplay = ({
{displayInfo.isDiversion && diversion} - {displayInfo.shipmentStatus === shipmentStatuses.CANCELED && ( - cancelled + {(displayInfo.shipmentStatus === shipmentStatuses.CANCELED || + displayInfo.status === shipmentStatuses.CANCELED || + displayInfo.ppmShipment?.status === ppmShipmentStatuses.CANCELED) && ( + canceled )} {displayInfo.shipmentStatus === shipmentStatuses.DIVERSION_REQUESTED && diversion requested} {displayInfo.shipmentStatus === shipmentStatuses.CANCELLATION_REQUESTED && ( diff --git a/src/components/Office/ShipmentDisplay/ShipmentDisplay.stories.jsx b/src/components/Office/ShipmentDisplay/ShipmentDisplay.stories.jsx index 2f4c4e3ef12..f9bbbb6ba14 100644 --- a/src/components/Office/ShipmentDisplay/ShipmentDisplay.stories.jsx +++ b/src/components/Office/ShipmentDisplay/ShipmentDisplay.stories.jsx @@ -9,7 +9,7 @@ import { ntsReleaseMissingInfo, postalOnlyInfo, diversionInfo, - cancelledInfo, + canceledInfo, ppmInfo, ppmInfoApprovedOrExcluded, ppmInfoRejected, @@ -250,11 +250,11 @@ export const DivertedShipment = () => (
); -export const CancelledShipment = () => ( +export const CanceledShipment = () => (
(
); -export const CancelledShipmentReadOnly = () => ( +export const CanceledShipmentReadOnly = () => (
{ render(); expect(screen.getByText('diversion')).toBeInTheDocument(); }); - it('renders with cancelled tag', () => { - render(); - expect(screen.getByText('cancelled')).toBeInTheDocument(); + it('renders with canceled tag', () => { + render(); + expect(screen.getByText('canceled')).toBeInTheDocument(); }); it('renders a disabled button when move is locked', () => { render( @@ -140,6 +140,10 @@ describe('Shipment Container', () => { ); expect(screen.getByTestId('shipment-display-checkbox')).toBeDisabled(); }); + it('renders with canceled tag', () => { + render(); + expect(screen.getByText('canceled')).toBeInTheDocument(); + }); }); describe('NTS-release shipment', () => { @@ -177,6 +181,10 @@ describe('Shipment Container', () => { expect(screen.getByText('external vendor')).toBeInTheDocument(); }); + it('renders with canceled tag', () => { + render(); + expect(screen.getByText('canceled')).toBeInTheDocument(); + }); it('renders with external vendor tag', () => { render( { ); expect(screen.getByTestId('tag', { name: 'packet ready for download' })).toBeInTheDocument(); }); + it('renders with canceled tag', () => { + render(); + expect(screen.getByText('canceled')).toBeInTheDocument(); + }); it('excluded', () => { render( diff --git a/src/components/Office/ShipmentDisplay/ShipmentDisplayTestData.js b/src/components/Office/ShipmentDisplay/ShipmentDisplayTestData.js index 5bf1b4e5a11..b3c584fc751 100644 --- a/src/components/Office/ShipmentDisplay/ShipmentDisplayTestData.js +++ b/src/components/Office/ShipmentDisplay/ShipmentDisplayTestData.js @@ -127,7 +127,7 @@ export const diversionInfo = { counselorRemarks: 'counselor approved', }; -export const cancelledInfo = { +export const canceledInfo = { heading: 'HHG', shipmentId: 'testShipmentId394', isDiversion: false, diff --git a/src/components/Office/ShipmentHeading/ShipmentHeading.jsx b/src/components/Office/ShipmentHeading/ShipmentHeading.jsx index cf01b46dd85..27ebdd71dc3 100644 --- a/src/components/Office/ShipmentHeading/ShipmentHeading.jsx +++ b/src/components/Office/ShipmentHeading/ShipmentHeading.jsx @@ -22,7 +22,7 @@ function ShipmentHeading({ shipmentInfo, handleShowCancellationModal, isMoveLock

{shipmentInfo.shipmentType}

- {shipmentStatus === shipmentStatuses.CANCELED && cancelled} + {shipmentStatus === shipmentStatuses.CANCELED && canceled} {shipmentInfo.isDiversion && diversion} {!shipmentInfo.isDiversion && shipmentStatus === shipmentStatuses.DIVERSION_REQUESTED && ( diversion requested diff --git a/src/components/Office/ShipmentHeading/ShipmentHeading.test.jsx b/src/components/Office/ShipmentHeading/ShipmentHeading.test.jsx index 910024714c5..27cacf3aa5d 100644 --- a/src/components/Office/ShipmentHeading/ShipmentHeading.test.jsx +++ b/src/components/Office/ShipmentHeading/ShipmentHeading.test.jsx @@ -41,7 +41,7 @@ describe('Shipment Heading with diversion requested shipment', () => { }); }); -describe('Shipment Heading with cancelled shipment', () => { +describe('Shipment Heading with canceled shipment', () => { const wrapper = mount( { , ); - it('renders the cancelled tag next to the shipment type', () => { - expect(wrapper.find({ 'data-testid': 'tag' }).text()).toEqual('cancelled'); + it('renders the canceled tag next to the shipment type', () => { + expect(wrapper.find({ 'data-testid': 'tag' }).text()).toEqual('canceled'); }); it('hides the request cancellation button', () => { diff --git a/src/components/Office/ShipmentHeading/shipmentheading.stories.jsx b/src/components/Office/ShipmentHeading/shipmentheading.stories.jsx index 9bc7911df34..e46d51cebee 100644 --- a/src/components/Office/ShipmentHeading/shipmentheading.stories.jsx +++ b/src/components/Office/ShipmentHeading/shipmentheading.stories.jsx @@ -49,7 +49,7 @@ export const shipmentHeadingDiversion = () => ( ); -export const shipmentHeadingCancelled = () => ( +export const shipmentHeadingCanceled = () => ( { const { moveCode } = useParams(); const [isFinancialModalVisible, setIsFinancialModalVisible] = useState(false); + const [isCancelMoveModalVisible, setIsCancelMoveModalVisible] = useState(false); const [alertMessage, setAlertMessage] = useState(null); const [alertType, setAlertType] = useState('success'); @@ -88,6 +91,7 @@ const MoveDetails = ({ // for now we are only showing dest type on retiree and separatee orders let isRetirementOrSeparation = false; + let numberOfShipmentsNotAllowedForCancel = 0; isRetirementOrSeparation = order?.order_type === ORDERS_TYPE.RETIREMENT || order?.order_type === ORDERS_TYPE.SEPARATION; @@ -121,6 +125,20 @@ const MoveDetails = ({ }, }); + const { mutate: mutateCancelMove } = useMutation(cancelMove, { + onSuccess: (data) => { + queryClient.setQueryData([MOVES, data.locator], data); + queryClient.invalidateQueries([MOVES, data.locator]); + queryClient.invalidateQueries({ queryKey: [MTO_SHIPMENTS] }); + setAlertMessage('Move canceled.'); + setAlertType('success'); + }, + onError: () => { + setAlertMessage('There was a problem cancelling the move. Please try again later.'); + setAlertType('error'); + }, + }); + const { mutate: mutateFinancialReview } = useMutation(updateFinancialFlag, { onSuccess: (data) => { queryClient.setQueryData([MOVES, data.locator], data); @@ -157,6 +175,23 @@ const MoveDetails = ({ const handleCancelFinancialReviewModal = () => { setIsFinancialModalVisible(false); }; + + const handleShowCancelMoveModal = () => { + setIsCancelMoveModalVisible(true); + }; + + const handleCancelMove = () => { + mutateCancelMove({ + moveID: move.id, + ifMatchETag: move.eTag, + }); + setIsCancelMoveModalVisible(false); + }; + + const handleCloseCancelMoveModal = () => { + setIsCancelMoveModalVisible(false); + }; + const submittedShipments = mtoShipments?.filter( (shipment) => shipment.status === shipmentStatuses.SUBMITTED && !shipment.deletedAt, ); @@ -180,6 +215,18 @@ const MoveDetails = ({ }, [shipmentWithDestinationAddressChangeRequest?.length, setShipmentsWithDeliveryAddressUpdateRequestedCount]); const shipmentsInfoNonPPM = mtoShipments?.filter((shipment) => shipment.shipmentType !== 'PPM'); + if (mtoShipments) { + const nonDeletedShipments = mtoShipments?.filter((shipment) => !shipment.deletedAt); + const nonPpmShipments = nonDeletedShipments.filter((shipment) => shipment.shipmentType !== 'PPM'); + const nonPpmApprovedShipments = nonPpmShipments.filter( + (shipment) => shipment?.status === shipmentStatuses.APPROVED, + ); + const onlyPpmShipments = nonDeletedShipments.filter((shipment) => shipment.shipmentType === 'PPM'); + const ppmCloseoutCompleteShipments = onlyPpmShipments.filter( + (shipment) => shipment.ppmShipment?.status === ppmShipmentStatuses.CLOSEOUT_COMPLETE, + ); + numberOfShipmentsNotAllowedForCancel = nonPpmApprovedShipments.length + ppmCloseoutCompleteShipments.length; + } useEffect(() => { const shipmentCount = submittedShipments?.length || 0; @@ -329,6 +376,7 @@ const MoveDetails = ({ const hasAmendedOrders = ordersInfo.uploadedAmendedOrderID && !ordersInfo.amendedOrdersAcknowledgedAt; const hasDestinationAddressUpdate = shipmentWithDestinationAddressChangeRequest && shipmentWithDestinationAddressChangeRequest.length > 0; + const tooCanCancelMove = move.status !== MOVE_STATUSES.CANCELED && numberOfShipmentsNotAllowedForCancel === 0; return (
@@ -374,17 +422,28 @@ const MoveDetails = ({ -
-

Move details

- -
- -
-
+
+ + {alertMessage && ( + + + {alertMessage} + + + )} + + +

Move details

+ +
+ +
+
+
{isFinancialModalVisible && ( )} - {alertMessage && ( - - - {alertMessage} - - - )} + + +
+ {tooCanCancelMove && !isMoveLocked && ( + + )} +
+
+
+ {submittedShipments?.length > 0 && (
{ expect(screen.queryByRole('link', { name: 'Edit allowances' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Edit customer info' })).not.toBeInTheDocument(); }); + + it('renders the cancel move button when user has permission', async () => { + render( + + + , + ); + + expect(await screen.getByText('Cancel move')).toBeInTheDocument(); + }); + + it('does not show the cancel move button if user does not have permission', () => { + render( + + + , + ); + + expect(screen.queryByText('Cancel move')).not.toBeInTheDocument(); + }); }); describe('when MTO shipments are not yet defined', () => { diff --git a/src/pages/Office/MoveTaskOrder/MoveTaskOrder.jsx b/src/pages/Office/MoveTaskOrder/MoveTaskOrder.jsx index 97c119a8825..3b0ff2efc62 100644 --- a/src/pages/Office/MoveTaskOrder/MoveTaskOrder.jsx +++ b/src/pages/Office/MoveTaskOrder/MoveTaskOrder.jsx @@ -1219,7 +1219,7 @@ export const MoveTaskOrder = (props) => { originPostalCode: pickupAddress?.postalCode || '', destinationAddress: destinationAddress || dutyLocationPostal, scheduledPickupDate: formattedScheduledPickup, - shipmentStatus: mtoShipment.status, + shipmentStatus: mtoShipment.ppmShipment?.status || mtoShipment.status, ifMatchEtag: mtoShipment.eTag, moveTaskOrderID: mtoShipment.moveTaskOrderID, shipmentLocator: mtoShipment.shipmentLocator, diff --git a/src/pages/Office/MoveTaskOrder/MoveTaskOrder.test.jsx b/src/pages/Office/MoveTaskOrder/MoveTaskOrder.test.jsx index 39736569bfa..0f114276dc1 100644 --- a/src/pages/Office/MoveTaskOrder/MoveTaskOrder.test.jsx +++ b/src/pages/Office/MoveTaskOrder/MoveTaskOrder.test.jsx @@ -4,7 +4,7 @@ import { render, screen } from '@testing-library/react'; import { unapprovedMTOQuery, - approvedMTOWithCancelledShipmentQuery, + approvedMTOWithCanceledShipmentQuery, missingWeightQuery, someShipmentsApprovedMTOQuery, someWeightNotReturned, @@ -685,8 +685,8 @@ describe('MoveTaskOrder', () => { }); }); - describe('approved mto with cancelled shipment', () => { - useMoveTaskOrderQueries.mockReturnValue(approvedMTOWithCancelledShipmentQuery); + describe('approved mto with canceled shipment', () => { + useMoveTaskOrderQueries.mockReturnValue(approvedMTOWithCanceledShipmentQuery); const wrapper = mount( { it('renders the ShipmentHeading', () => { expect(wrapper.find('ShipmentHeading').exists()).toBe(true); expect(wrapper.find('h2').at(0).text()).toEqual('Household goods'); - expect(wrapper.find('span[data-testid="tag"]').at(0).text()).toEqual('cancelled'); + expect(wrapper.find('span[data-testid="tag"]').at(0).text()).toEqual('canceled'); }); it('renders the ImportantShipmentDates', () => { diff --git a/src/pages/Office/MoveTaskOrder/moveTaskOrderUnitTestData.js b/src/pages/Office/MoveTaskOrder/moveTaskOrderUnitTestData.js index ffd30aea60a..ea37799f7b9 100644 --- a/src/pages/Office/MoveTaskOrder/moveTaskOrderUnitTestData.js +++ b/src/pages/Office/MoveTaskOrder/moveTaskOrderUnitTestData.js @@ -1268,7 +1268,7 @@ export const riskOfExcessWeightQueryExternalShipment = { ], }; -export const approvedMTOWithCancelledShipmentQuery = { +export const approvedMTOWithCanceledShipmentQuery = { orders: { 1: { id: '1', diff --git a/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.jsx b/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.jsx index c5be3bf99f0..5db21407df5 100644 --- a/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.jsx +++ b/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.jsx @@ -10,7 +10,7 @@ import styles from '../ServicesCounselingMoveInfo/ServicesCounselingTab.module.s import scMoveDetailsStyles from './ServicesCounselingMoveDetails.module.scss'; -import { MOVES } from 'constants/queryKeys'; +import { MOVES, MTO_SHIPMENTS } from 'constants/queryKeys'; import { ORDERS_TYPE } from 'constants/orders'; import { servicesCounselingRoutes } from 'constants/routes'; import AllowancesList from 'components/Office/DefinitionLists/AllowancesList'; @@ -19,12 +19,13 @@ import OrdersList from 'components/Office/DefinitionLists/OrdersList'; import DetailsPanel from 'components/Office/DetailsPanel/DetailsPanel'; import FinancialReviewButton from 'components/Office/FinancialReviewButton/FinancialReviewButton'; import FinancialReviewModal from 'components/Office/FinancialReviewModal/FinancialReviewModal'; +import CancelMoveConfirmationModal from 'components/ConfirmationModals/CancelMoveConfirmationModal'; import ShipmentDisplay from 'components/Office/ShipmentDisplay/ShipmentDisplay'; import { SubmitMoveConfirmationModal } from 'components/Office/SubmitMoveConfirmationModal/SubmitMoveConfirmationModal'; import { useMoveDetailsQueries, useOrdersDocumentQueries } from 'hooks/queries'; -import { updateMoveStatusServiceCounselingCompleted, updateFinancialFlag } from 'services/ghcApi'; +import { updateMoveStatusServiceCounselingCompleted, cancelMove, updateFinancialFlag } from 'services/ghcApi'; import { MOVE_STATUSES, SHIPMENT_OPTIONS_URL, SHIPMENT_OPTIONS } from 'shared/constants'; -import { ppmShipmentStatuses } from 'constants/shipments'; +import { ppmShipmentStatuses, shipmentStatuses } from 'constants/shipments'; import shipmentCardsStyles from 'styles/shipmentCards.module.scss'; import LeftNav from 'components/LeftNav/LeftNav'; import LeftNavTag from 'components/LeftNavTag/LeftNavTag'; @@ -59,6 +60,7 @@ const ServicesCounselingMoveDetails = ({ const [moveHasExcessWeight, setMoveHasExcessWeight] = useState(false); const [isSubmitModalVisible, setIsSubmitModalVisible] = useState(false); const [isFinancialModalVisible, setIsFinancialModalVisible] = useState(false); + const [isCancelMoveModalVisible, setIsCancelMoveModalVisible] = useState(false); const { upload, amendedUpload } = useOrdersDocumentQueries(moveCode); const documentsForViewer = Object.values(upload || {}) .concat(Object.values(amendedUpload || {})) @@ -76,6 +78,7 @@ const ServicesCounselingMoveDetails = ({ let counselorCanReview; let reviewWeightsURL; let counselorCanEdit; + let counselorCanCancelMove; let counselorCanEditNonPPM; const sections = useMemo(() => { @@ -116,6 +119,7 @@ const ServicesCounselingMoveDetails = ({ let shipmentsInfo = []; let ppmShipmentsInfoNeedsApproval = []; let ppmShipmentsOtherStatuses = []; + let numberOfShipmentsNotAllowedForCancel = 0; let disableSubmit = false; let disableSubmitDueToMissingOrderInfo = false; let numberOfErrorIfMissingForAllShipments = 0; @@ -168,6 +172,15 @@ const ServicesCounselingMoveDetails = ({ (shipment) => shipment.ppmShipment?.status !== ppmShipmentStatuses.NEEDS_CLOSEOUT, ); + const nonPpmShipments = submittedShipments.filter((shipment) => shipment.shipmentType !== 'PPM'); + const nonPpmApprovedShipments = nonPpmShipments.filter( + (shipment) => shipment?.status === shipmentStatuses.APPROVED, + ); + const ppmCloseoutCompleteShipments = onlyPpmShipments.filter( + (shipment) => shipment.ppmShipment?.status === ppmShipmentStatuses.CLOSEOUT_COMPLETE, + ); + numberOfShipmentsNotAllowedForCancel = nonPpmApprovedShipments.length + ppmCloseoutCompleteShipments.length; + ppmShipmentsInfoNeedsApproval = ppmNeedsApprovalShipments.map((shipment) => { const reviewURL = `../${generatePath(servicesCounselingRoutes.SHIPMENT_REVIEW_PATH, { moveCode, @@ -229,6 +242,7 @@ const ServicesCounselingMoveDetails = ({ counselorCanReview = ppmShipmentsInfoNeedsApproval.length > 0; reviewWeightsURL = generatePath(servicesCounselingRoutes.BASE_REVIEW_SHIPMENT_WEIGHTS_PATH, { moveCode }); counselorCanEdit = move.status === MOVE_STATUSES.NEEDS_SERVICE_COUNSELING && ppmShipmentsOtherStatuses.length > 0; + counselorCanCancelMove = move.status !== MOVE_STATUSES.CANCELED && numberOfShipmentsNotAllowedForCancel === 0; counselorCanEditNonPPM = move.status === MOVE_STATUSES.NEEDS_SERVICE_COUNSELING && shipmentsInfo.shipmentType !== 'PPM'; @@ -395,6 +409,20 @@ const ServicesCounselingMoveDetails = ({ }, }); + const { mutate: mutateCancelMove } = useMutation(cancelMove, { + onSuccess: (data) => { + queryClient.setQueryData([MOVES, data.locator], data); + queryClient.invalidateQueries([MOVES, data.locator]); + queryClient.invalidateQueries({ queryKey: [MTO_SHIPMENTS] }); + setAlertMessage('Move canceled.'); + setAlertType('success'); + }, + onError: () => { + setAlertMessage('There was a problem cancelling the move. Please try again later.'); + setAlertType('error'); + }, + }); + const { mutate: mutateFinancialReview } = useMutation(updateFinancialFlag, { onSuccess: (data) => { queryClient.setQueryData([MOVES, data.locator], data); @@ -447,7 +475,7 @@ const ServicesCounselingMoveDetails = ({ if (isLoading) return ; if (isError) return ; - const handleShowCancellationModal = () => { + const handleShowSubmitMoveModal = () => { setIsSubmitModalVisible(true); }; @@ -474,6 +502,21 @@ const ServicesCounselingMoveDetails = ({ setIsFinancialModalVisible(false); }; + const handleShowCancelMoveModal = () => { + setIsCancelMoveModalVisible(true); + }; + + const handleCancelMove = () => { + mutateCancelMove({ + moveID: move.id, + }); + setIsCancelMoveModalVisible(false); + }; + + const handleCloseCancelMoveModal = () => { + setIsCancelMoveModalVisible(false); + }; + const counselorCanEditOrdersAndAllowances = () => { if (counselorCanEdit || counselorCanEditNonPPM) return true; if ( @@ -538,6 +581,11 @@ const ServicesCounselingMoveDetails = ({ initialSelection={move?.financialReviewFlag} /> )} + @@ -577,7 +625,7 @@ const ServicesCounselingMoveDetails = ({ isMoveLocked } type="button" - onClick={handleShowCancellationModal} + onClick={handleShowSubmitMoveModal} > Submit move details @@ -585,6 +633,17 @@ const ServicesCounselingMoveDetails = ({
)} + + +
+ {counselorCanCancelMove && !isMoveLocked && ( + + )} +
+
+
{hasInvalidProGearAllowances ? ( diff --git a/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.module.scss b/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.module.scss index 5e7a5add39d..70809786fa5 100644 --- a/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.module.scss +++ b/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.module.scss @@ -28,6 +28,11 @@ @include u-margin-bottom(3); } + .scCancelMoveContainer { + @include u-margin-top(1); + padding-left: 810px; + } + .alertContainer { @include u-margin-bottom(4); } diff --git a/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.test.jsx b/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.test.jsx index 2e3c7a329a8..d3484679fe8 100644 --- a/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.test.jsx +++ b/src/pages/Office/ServicesCounselingMoveDetails/ServicesCounselingMoveDetails.test.jsx @@ -1144,6 +1144,31 @@ describe('MoveDetails page', () => { expect(screen.queryByText('Edit customer info')).not.toBeInTheDocument(); }); + + it('renders the cancel move button when user has permission', async () => { + render( + + + , + ); + + expect(await screen.getByText('Cancel move')).toBeInTheDocument(); + }); + + it('does not show the cancel move button if user does not have permission', () => { + render( + + + , + ); + + expect(screen.queryByText('Cancel move')).not.toBeInTheDocument(); + }); }); }); }); diff --git a/src/pages/Office/TXOMoveInfo/TXOTab.module.scss b/src/pages/Office/TXOMoveInfo/TXOTab.module.scss index 50465a7f999..86ae35a376f 100644 --- a/src/pages/Office/TXOMoveInfo/TXOTab.module.scss +++ b/src/pages/Office/TXOMoveInfo/TXOTab.module.scss @@ -42,11 +42,16 @@ flex-direction: row; justify-content: space-between; align-items: baseline; - padding-bottom: 30px; } .tooMoveDetailsH1 { @include u-margin-bottom(0); + align-self: center; + } + + .tooCancelMoveContainer { + @include u-margin-bottom(3); + padding-left: 810px; } .gridContainer { diff --git a/src/services/ghcApi.js b/src/services/ghcApi.js index f3476b7c253..b99dc5c91c9 100644 --- a/src/services/ghcApi.js +++ b/src/services/ghcApi.js @@ -432,6 +432,17 @@ export function updateMoveStatusServiceCounselingCompleted({ moveTaskOrderID, if ); } +export function cancelMove({ moveID, normalize = false }) { + const operationPath = 'move.moveCanceler'; + return makeGHCRequest( + operationPath, + { + moveID, + }, + { normalize }, + ); +} + export function updateMTOShipmentStatus({ shipmentID, diversionReason, diff --git a/swagger-def/ghc.yaml b/swagger-def/ghc.yaml index 5dddb4f87bc..1aa19cd96c3 100644 --- a/swagger-def/ghc.yaml +++ b/swagger-def/ghc.yaml @@ -384,7 +384,7 @@ paths: parameters: [] responses: '200': - description: Successfully cancelled move + description: Successfully canceled move schema: $ref: '#/definitions/Move' '403': @@ -404,6 +404,8 @@ paths: description: cancels a move operationId: moveCanceler summary: Cancels a move + x-permissions: + - update.cancelMoveFlag '/counseling/orders/{orderID}': parameters: - description: ID of order to update @@ -3716,6 +3718,7 @@ paths: - APPROVED - NEEDS SERVICE COUNSELING - SERVICE COUNSELING COMPLETED + - CANCELED originPostalCode: type: string x-nullable: true @@ -5148,7 +5151,7 @@ definitions: PPMStatus: type: string enum: - - CANCELLED + - CANCELED - DRAFT - SUBMITTED - WAITING_ON_CUSTOMER diff --git a/swagger/ghc.yaml b/swagger/ghc.yaml index 37d14b07416..6b89514f604 100644 --- a/swagger/ghc.yaml +++ b/swagger/ghc.yaml @@ -422,7 +422,7 @@ paths: parameters: [] responses: '200': - description: Successfully cancelled move + description: Successfully canceled move schema: $ref: '#/definitions/Move' '403': @@ -442,6 +442,8 @@ paths: description: cancels a move operationId: moveCanceler summary: Cancels a move + x-permissions: + - update.cancelMoveFlag /counseling/orders/{orderID}: parameters: - description: ID of order to update @@ -3887,6 +3889,7 @@ paths: - APPROVED - NEEDS SERVICE COUNSELING - SERVICE COUNSELING COMPLETED + - CANCELED originPostalCode: type: string x-nullable: true @@ -5369,7 +5372,7 @@ definitions: PPMStatus: type: string enum: - - CANCELLED + - CANCELED - DRAFT - SUBMITTED - WAITING_ON_CUSTOMER