diff --git a/react/src/components/HistoryOverlay/HistoryOverlay.tsx b/react/src/components/HistoryOverlay/HistoryOverlay.tsx new file mode 100644 index 000000000..ee3847dde --- /dev/null +++ b/react/src/components/HistoryOverlay/HistoryOverlay.tsx @@ -0,0 +1,40 @@ +import { + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react"; +import Timeline, { IGroupedRecordEntry } from "../Timeline/Timeline"; + +interface IHistoryOverlay { + data: IGroupedRecordEntry[]; + isOpen: boolean; + onClose: () => void; +} + +function HistoryOverlay({ data, isOpen, onClose }: IHistoryOverlay) { + return ( + + + + Box History + + + + + + + + ); +} + +export default HistoryOverlay; diff --git a/react/src/components/Table/Filter.tsx b/react/src/components/Table/Filter.tsx index b9bf183bf..d91c431a6 100644 --- a/react/src/components/Table/Filter.tsx +++ b/react/src/components/Table/Filter.tsx @@ -52,7 +52,7 @@ export function SelectColumnFilter({ ({ label, value: optionValues[label], - } as ISelectOption), + }) as ISelectOption, ); }, [id, preFilteredRows]); diff --git a/react/src/components/Timeline/Timeline.tsx b/react/src/components/Timeline/Timeline.tsx new file mode 100644 index 000000000..7b82437c0 --- /dev/null +++ b/react/src/components/Timeline/Timeline.tsx @@ -0,0 +1,55 @@ +import { Box, Flex, Text } from "@chakra-ui/react"; +import { User } from "types/generated/graphql"; +import TimelineEntry from "./components/TimelineEntry"; + +export interface ITimelineEntry { + action: string; + createdOn: Date; + createdBy: User; +} + +export interface IGroupedRecordEntry { + date: string; + entries: (ITimelineEntry | null | undefined)[]; +} + +export interface ITimelineProps { + records: IGroupedRecordEntry[]; +} + +function Timeline({ records }: ITimelineProps) { + return ( + + {records.map(({ date, entries }, index) => ( + + + + {date} + + + + {entries?.map((entry, indx) => ( + + ))} + + + ))} + + ); +} + +export default Timeline; diff --git a/react/src/views/Transfers/ShipmentView/components/TimelineBullet.tsx b/react/src/components/Timeline/components/TimelineBullet.tsx similarity index 100% rename from react/src/views/Transfers/ShipmentView/components/TimelineBullet.tsx rename to react/src/components/Timeline/components/TimelineBullet.tsx diff --git a/react/src/views/Transfers/ShipmentView/components/TimelineEntry.tsx b/react/src/components/Timeline/components/TimelineEntry.tsx similarity index 85% rename from react/src/views/Transfers/ShipmentView/components/TimelineEntry.tsx rename to react/src/components/Timeline/components/TimelineEntry.tsx index effdc8d31..4d8d9d530 100644 --- a/react/src/views/Transfers/ShipmentView/components/TimelineEntry.tsx +++ b/react/src/components/Timeline/components/TimelineEntry.tsx @@ -1,9 +1,10 @@ import { Flex, Text } from "@chakra-ui/react"; +import { formatTime } from "utils/helpers"; import TimelineBullet from "./TimelineBullet"; export interface ITimelineEntryProps { content: string | undefined; - time: string | undefined; + time: string | Date | undefined; } function TimelineEntry({ content, time }: ITimelineEntryProps) { @@ -17,7 +18,7 @@ function TimelineEntry({ content, time }: ITimelineEntryProps) { {content} - {time} + {time ? formatTime(time) : ""} diff --git a/react/src/hooks/useLabelIdentifierResolver.ts b/react/src/hooks/useLabelIdentifierResolver.ts index 3ca9c1b76..d937eb930 100644 --- a/react/src/hooks/useLabelIdentifierResolver.ts +++ b/react/src/hooks/useLabelIdentifierResolver.ts @@ -65,7 +65,7 @@ export const useLabelIdentifierResolver = () => { kind: ILabelIdentifierResolverResultKind.FAIL, labelIdentifier, error: err, - } as ILabelIdentifierResolvedValue), + }) as ILabelIdentifierResolvedValue, ); setLoading(false); return labelIdentifierResolvedValue; diff --git a/react/src/hooks/useScannedBoxesActions.ts b/react/src/hooks/useScannedBoxesActions.ts index a2e8883dd..3229cdd8d 100644 --- a/react/src/hooks/useScannedBoxesActions.ts +++ b/react/src/hooks/useScannedBoxesActions.ts @@ -72,7 +72,7 @@ export const useScannedBoxesActions = () => { (data: IScannedBoxesData) => ({ scannedBoxes: data.scannedBoxes.slice(0, -1), - } as IScannedBoxesData), + }) as IScannedBoxesData, ); }, [apolloClient]); @@ -84,7 +84,7 @@ export const useScannedBoxesActions = () => { (data: IScannedBoxesData) => ({ scannedBoxes: data.scannedBoxes.filter((box) => box.state === BoxState.InStock), - } as IScannedBoxesData), + }) as IScannedBoxesData, ); }, [apolloClient]); @@ -99,7 +99,7 @@ export const useScannedBoxesActions = () => { scannedBoxes: data.scannedBoxes.filter( (box) => !labelIdentifiers.includes(box.labelIdentifier), ), - } as IScannedBoxesData), + }) as IScannedBoxesData, ); }, [apolloClient], diff --git a/react/src/utils/helpers.ts b/react/src/utils/helpers.ts index 37fa36f22..82b799c8e 100644 --- a/react/src/utils/helpers.ts +++ b/react/src/utils/helpers.ts @@ -71,3 +71,31 @@ export const formatDateKey = (date: Date): string => { return `${date.toLocaleString("default", { month: "short" })} ${date.getDate()}, ${date.getFullYear()}`; }; + +export const prepareBoxHistoryEntryText = (text: string): string => { + // Remove the last character if it is a semicolon + const trimmedText = text?.endsWith(";") ? text?.slice(0, -1) : text; + + // Replace "box state" with "box status" (ref. trello card https://trello.com/c/ClAikFIk) + const updatedText = trimmedText?.replace("box state", "box status"); + + return updatedText; +}; + +/** + * Formats a given date or string into a string representation of the time. + * + * @param {Date | string} date - The date or string to be formatted. + * @return {string} The formatted time as a string in the format "HH:MM". + */ +export const formatTime = (date: Date | string): string => { + const formattedDate = typeof date === "string" && date !== "" ? new Date(date) : date; + + if (formattedDate instanceof Date) { + const hours = formattedDate.getHours().toString().padStart(2, "0"); + const minutes = formattedDate.getMinutes().toString().padStart(2, "0"); + return `${hours}:${minutes}`; + } + + return ""; +}; diff --git a/react/src/views/Box/BoxView.test.tsx b/react/src/views/Box/BoxView.test.tsx index 3103e565b..ca8a88d13 100644 --- a/react/src/views/Box/BoxView.test.tsx +++ b/react/src/views/Box/BoxView.test.tsx @@ -20,7 +20,7 @@ import { tags } from "mocks/tags"; import { selectOptionInSelectField, textContentMatcher } from "tests/helpers"; import BoxDetails from "./components/BoxDetails"; import { generateMockTransferAgreement } from "mocks/transferAgreements"; -import { mockGraphQLError, mockNetworkError } from "mocks/functions"; +import { mockGraphQLError, mockMatchMediaQuery, mockNetworkError } from "mocks/functions"; import { BOX_BY_LABEL_IDENTIFIER_AND_ALL_SHIPMENTS_QUERY } from "queries/queries"; import { organisation1 } from "mocks/organisations"; import { generateMockShipment, shipment1 } from "mocks/shipments"; @@ -307,21 +307,8 @@ const moveLocationOfBoxNetworkFailedMutation = { }; beforeEach(() => { - // we need to mock matchmedia - // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom - Object.defineProperty(window, "matchMedia", { - writable: true, - value: jest.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // Deprecated - removeListener: jest.fn(), // Deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); + // setting the screensize to + mockMatchMediaQuery(true); const mockedUseErrorHandling = jest.mocked(useErrorHandling); mockedUseErrorHandling.mockReturnValue({ triggerError: mockedTriggerError }); const mockedUseNotification = jest.mocked(useNotification); @@ -777,6 +764,7 @@ it("3.1.10 - No Data or Null Data Fetched for a given Box Label Identifier", asy (); - + const { isOpen: isHistoryOpen, onOpen: onHistoryOpen, onClose: onHistoryClose } = useDisclosure(); const { assignBoxesToShipment, unassignBoxesToShipment, @@ -169,6 +175,38 @@ function BTBox() { ? [BoxState.Receiving, BoxState.MarkedForShipment, BoxState.InTransit].includes(currentBoxState) : false; + // map over each box HistoryEntry to compile its timeline records + const boxLogs: ITimelineEntry[] = (allData.data?.box?.history as HistoryEntry[])?.flatMap( + (histories) => + _.compact([ + histories?.user && { + action: prepareBoxHistoryEntryText(`${histories.user.name} ${histories.changes}`), + createdBy: histories.user as User, + createdOn: new Date(histories.changeDate), + }, + ]), + ) as ITimelineEntry[]; + + const allLogs = _.orderBy( + _.sortBy(_.concat([...(boxLogs || [])]), "createdOn"), + ["createdOn"], + ["asc", "desc"], + ); + const groupedHistoryEntries: _.Dictionary = _.groupBy( + allLogs, + (log) => `${formatDateKey(log?.createdOn)}`, + ); + + // sort each array of history entries in descending order + const sortedGroupedHistoryEntries = _(groupedHistoryEntries) + .toPairs() + .map(([date, entries]) => ({ + date, + entries: _.orderBy(entries, (entry) => new Date(entry?.createdOn), "desc"), + })) + .orderBy((entry) => new Date(entry.date), "desc") + .value(); + const [updateNumberOfItemsMutation, updateNumberOfItemsMutationStatus] = useMutation< UpdateNumberOfItemsMutation, UpdateNumberOfItemsMutationVariables @@ -596,6 +634,7 @@ function BTBox() { boxData={boxData} boxInTransit={boxInTransit} onPlusOpen={onPlusOpen} + onHistoryOpen={onHistoryOpen} onMinusOpen={onMinusOpen} onMoveToLocationClick={onMoveBoxToLocationClick} onStateChange={onStateChange} @@ -630,6 +669,11 @@ function BTBox() { closeOnOverlayClick={false} redirectToShipmentView /> + ); } diff --git a/react/src/views/Box/BoxViewHistoryOverlay.test.tsx b/react/src/views/Box/BoxViewHistoryOverlay.test.tsx new file mode 100644 index 000000000..54563dc75 --- /dev/null +++ b/react/src/views/Box/BoxViewHistoryOverlay.test.tsx @@ -0,0 +1,127 @@ +/* eslint-disable */ +import "@testing-library/jest-dom"; +import { screen, render } from "tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { cache } from "queries/cache"; + +import { useErrorHandling } from "hooks/useErrorHandling"; +import { useNotification } from "hooks/useNotification"; + +import { BOX_BY_LABEL_IDENTIFIER_AND_ALL_SHIPMENTS_QUERY } from "queries/queries"; +import { organisation1 } from "mocks/organisations"; +import BTBox from "./BoxView"; + +import { BoxState } from "types/generated/graphql"; +import { history1, history2 } from "mocks/histories"; +import { generateMockBox } from "mocks/boxes"; +import { mockMatchMediaQuery } from "mocks/functions"; + +const mockedTriggerError = jest.fn(); +const mockedCreateToast = jest.fn(); +jest.mock("hooks/useErrorHandling"); +jest.mock("hooks/useNotification"); + +cache.reset(); + +const initialQueryForBoxWithHistory = { + request: { + query: BOX_BY_LABEL_IDENTIFIER_AND_ALL_SHIPMENTS_QUERY, + variables: { + labelIdentifier: "123", + }, + notifyOnNetworkStatusChange: true, + }, + result: { + data: { + box: generateMockBox({ + labelIdentifier: "123", + state: BoxState.InStock, + histories: [history1, history2], + }), + }, + }, +}; + +// Test case 3.1.12 +describe("3.1.12 - Box HistoryOverlay on BoxView", () => { + beforeEach(() => { + // setting the screensize to + mockMatchMediaQuery(true); + const mockedUseErrorHandling = jest.mocked(useErrorHandling); + mockedUseErrorHandling.mockReturnValue({ triggerError: mockedTriggerError }); + const mockedUseNotification = jest.mocked(useNotification); + mockedUseNotification.mockReturnValue({ createToast: mockedCreateToast }); + }); + + // Test case 3.1.12.1 + it("3.1.12.1 - When more than one entry is available displays history icon", async () => { + render(, { + routePath: "/bases/:baseId/boxes/:labelIdentifier", + initialUrl: "/bases/1/boxes/123", + additionalRoute: "/bases/1/shipment/1", + mocks: [initialQueryForBoxWithHistory], + addTypename: true, + cache, + globalPreferences: { + dispatch: jest.fn(), + globalPreferences: { + organisation: { id: organisation1.id, name: organisation1.name }, + availableBases: organisation1.bases, + selectedBase: organisation1.bases[0], + }, + }, + }); + + const heading = await screen.findByRole("heading", { name: /box 123/i }); + expect(heading).toBeInTheDocument(); + + const showHistoryButton = await screen.findByRole("button", { + name: /show detail history/i, + }); + + expect(showHistoryButton).toBeInTheDocument(); + }, 10000); + + // Test case 3.1.12.2 + it("3.1.12.2 - Click on history icons opens history overlay", async () => { + const user = userEvent.setup(); + + render(, { + routePath: "/bases/:baseId/boxes/:labelIdentifier", + initialUrl: "/bases/1/boxes/123", + additionalRoute: "/bases/1/shipment/1", + mocks: [initialQueryForBoxWithHistory], + addTypename: true, + cache, + globalPreferences: { + dispatch: jest.fn(), + globalPreferences: { + organisation: { id: organisation1.id, name: organisation1.name }, + availableBases: organisation1.bases, + selectedBase: organisation1.bases[0], + }, + }, + }); + + const heading = await screen.findByRole("heading", { name: /box 123/i }); + expect(heading).toBeInTheDocument(); + + const historyButton = await screen.findByRole("button", { + name: /show detail history/i, + }); + + expect(historyButton).toBeInTheDocument(); + + await user.click(historyButton); + + const banner = await screen.findByRole("banner"); + + expect(banner).toBeInTheDocument(); + expect(screen.getByText(/jan 14, 2023/i)).toBeInTheDocument(); + expect( + screen.getByText(/dev coordinator changed box location from wh men to wh women/i), + ).toBeInTheDocument(); + expect(screen.getByText(/jan 12, 2023/i)).toBeInTheDocument(); + expect(screen.getByText(/dev coordinator created record/i)).toBeInTheDocument(); + }, 10000); +}); diff --git a/react/src/views/Box/BoxViewReconciliationOverlay.test.tsx b/react/src/views/Box/BoxViewReconciliationOverlay.test.tsx index 71296b46c..2bebb9101 100644 --- a/react/src/views/Box/BoxViewReconciliationOverlay.test.tsx +++ b/react/src/views/Box/BoxViewReconciliationOverlay.test.tsx @@ -18,6 +18,7 @@ import { products } from "mocks/products"; import { tag1, tag2 } from "mocks/tags"; import { generateMockShipment } from "mocks/shipments"; import { ShipmentState } from "types/generated/graphql"; +import { mockMatchMediaQuery } from "mocks/functions"; const mockedTriggerError = jest.fn(); const mockedCreateToast = jest.fn(); @@ -126,21 +127,8 @@ const queryShipmentDetailForBoxReconciliation = { }; beforeEach(() => { - // we need to mock matchmedia - // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom - Object.defineProperty(window, "matchMedia", { - writable: true, - value: jest.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // Deprecated - removeListener: jest.fn(), // Deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); + // setting the screensize to + mockMatchMediaQuery(true); const mockedUseErrorHandling = jest.mocked(useErrorHandling); mockedUseErrorHandling.mockReturnValue({ triggerError: mockedTriggerError }); const mockedUseNotification = jest.mocked(useNotification); diff --git a/react/src/views/Box/components/BoxCard.tsx b/react/src/views/Box/components/BoxCard.tsx index 3ab1a9416..8a8079286 100644 --- a/react/src/views/Box/components/BoxCard.tsx +++ b/react/src/views/Box/components/BoxCard.tsx @@ -22,7 +22,9 @@ import { SkeletonCircle, Skeleton, SkeletonText, + Icon, } from "@chakra-ui/react"; +import { MdHistory } from "react-icons/md"; import { NavLink } from "react-router-dom"; import { BoxByLabelIdentifierQuery, @@ -38,6 +40,7 @@ import HistoryEntries from "./HistoryEntries"; export interface IBoxCardProps { boxData: BoxByLabelIdentifierQuery["box"] | UpdateLocationOfBoxMutation["updateBox"]; boxInTransit: boolean; + onHistoryOpen: () => void; onPlusOpen: () => void; onMinusOpen: () => void; onStateChange: (boxState: BoxState) => void; @@ -47,6 +50,7 @@ export interface IBoxCardProps { function BoxCard({ boxData, boxInTransit, + onHistoryOpen, onPlusOpen, onMinusOpen, onStateChange, @@ -333,12 +337,28 @@ function BoxCard({ History:   - {!isLoading && ( - - )} - {isLoading && ( - - )} + + {!isLoading && ( + + )} + {isLoading && ( + + )} + {boxData?.history && boxData?.history?.length > 1 && ( + <> + + } + /> + + )} + diff --git a/react/src/views/Box/components/BoxDetails.tsx b/react/src/views/Box/components/BoxDetails.tsx index 17bfca5bc..71377b6ab 100644 --- a/react/src/views/Box/components/BoxDetails.tsx +++ b/react/src/views/Box/components/BoxDetails.tsx @@ -13,6 +13,7 @@ interface IBoxDetailsProps { boxData: BoxByLabelIdentifierQuery["box"] | UpdateLocationOfBoxMutation["updateBox"]; boxInTransit: boolean; onMoveToLocationClick: (locationId: string) => void; + onHistoryOpen: () => void; onPlusOpen: () => void; onMinusOpen: () => void; onStateChange: (boxState: BoxState) => void; @@ -30,6 +31,7 @@ function BoxDetails({ onMoveToLocationClick, onAssignBoxToDistributionEventClick, onUnassignBoxFromDistributionEventClick, + onHistoryOpen, onPlusOpen, onMinusOpen, onStateChange, @@ -53,6 +55,7 @@ function BoxDetails({ {historyEntry?.user?.name} {" on "} {formatDate(historyEntry?.changeDate)}{" "} - {prepareHistoryEntryText(historyEntry?.changes)} + {prepareBoxHistoryEntryText(historyEntry?.changes)} diff --git a/react/src/views/BoxEdit/BoxEditView.tsx b/react/src/views/BoxEdit/BoxEditView.tsx index 3b06f53ba..09523413b 100644 --- a/react/src/views/BoxEdit/BoxEditView.tsx +++ b/react/src/views/BoxEdit/BoxEditView.tsx @@ -135,7 +135,7 @@ function BoxEditView() { numberOfItems: boxEditFormData.numberOfItems, locationId: parseInt(boxEditFormData.locationId.value, 10), tagIds, - comment: boxEditFormData?.comment, + comment: boxEditFormData?.comment || null, }, }) .then((mutationResult) => { diff --git a/react/src/views/Transfers/CreateShipment/CreateShipmentView.tsx b/react/src/views/Transfers/CreateShipment/CreateShipmentView.tsx index e6b8936b5..fad51af92 100644 --- a/react/src/views/Transfers/CreateShipment/CreateShipmentView.tsx +++ b/react/src/views/Transfers/CreateShipment/CreateShipmentView.tsx @@ -144,7 +144,7 @@ function CreateShipmentView() { id: agreement.id, name: agreement.name, bases: agreement.bases, - } as IOrganisationBaseData), + }) as IOrganisationBaseData, ) .reduce((accumulator, currentOrg) => { // Merge options. If there are multiple transfer agreements this step is necessary diff --git a/react/src/views/Transfers/ShipmentView/ShipmentView.test.tsx b/react/src/views/Transfers/ShipmentView/ShipmentView.test.tsx index a606a851d..5a99590b9 100644 --- a/react/src/views/Transfers/ShipmentView/ShipmentView.test.tsx +++ b/react/src/views/Transfers/ShipmentView/ShipmentView.test.tsx @@ -12,6 +12,7 @@ import { BoxState, ShipmentState } from "types/generated/graphql"; import { useErrorHandling } from "hooks/useErrorHandling"; import { useNotification } from "hooks/useNotification"; import userEvent from "@testing-library/user-event"; +import { mockMatchMediaQuery } from "mocks/functions"; import ShipmentView, { SHIPMENT_BY_ID_QUERY } from "./ShipmentView"; const mockedTriggerError = jest.fn(); @@ -154,21 +155,8 @@ const initialRecevingUIAsTargetOrgQuery = { }; beforeEach(() => { - // we need to mock matchmedia - // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom - Object.defineProperty(window, "matchMedia", { - writable: true, - value: jest.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // Deprecated - removeListener: jest.fn(), // Deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); + // setting the screensize to + mockMatchMediaQuery(true); const mockedUseErrorHandling = jest.mocked(useErrorHandling); mockedUseErrorHandling.mockReturnValue({ triggerError: mockedTriggerError }); const mockedUseNotification = jest.mocked(useNotification); @@ -177,21 +165,8 @@ beforeEach(() => { describe("4.5 Test Cases", () => { beforeEach(() => { - // we need to mock matchmedia - // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom - Object.defineProperty(window, "matchMedia", { - writable: true, - value: jest.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // Deprecated - removeListener: jest.fn(), // Deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); + // setting the screensize to + mockMatchMediaQuery(true); }); // Test case 4.5.1 diff --git a/react/src/views/Transfers/ShipmentView/ShipmentView.tsx b/react/src/views/Transfers/ShipmentView/ShipmentView.tsx index 5e97ab864..a3e855bf2 100644 --- a/react/src/views/Transfers/ShipmentView/ShipmentView.tsx +++ b/react/src/views/Transfers/ShipmentView/ShipmentView.tsx @@ -45,13 +45,27 @@ import { BoxReconciliationOverlay } from "components/BoxReconciliationOverlay/Bo import { UPDATE_SHIPMENT_WHEN_RECEIVING } from "queries/mutations"; import { boxReconciliationOverlayVar } from "queries/cache"; import { MobileBreadcrumbButton } from "components/BreadcrumbNavigation"; +import { ITimelineEntry } from "components/Timeline/Timeline"; import ShipmentCard from "./components/ShipmentCard"; -import ShipmentTabs, { IShipmentHistory, ShipmentActionEvent } from "./components/ShipmentTabs"; +import ShipmentTabs from "./components/ShipmentTabs"; import ShipmentOverlay, { IShipmentOverlayData } from "./components/ShipmentOverlay"; import ShipmentActionButtons from "./components/ShipmentActionButtons"; import ShipmentReceivingContent from "./components/ShipmentReceivingContent"; import ShipmentReceivingCard from "./components/ShipmentReceivingCard"; +// eslint-disable-next-line no-shadow +enum ShipmentActionEvent { + ShipmentStarted = "Shipment Started", + ShipmentCanceled = "Shipment Canceled", + ShipmentSent = "Shipment Sent", + ShipmentStartReceiving = "Shipment Being Received", + ShipmentCompleted = "Shipment Completed", + BoxAdded = "Box Added", + BoxRemoved = "Box Removed", + BoxLost = "Box Marked Lost", + BoxReceived = "Box Received", +} + // graphql query and mutations export const SHIPMENT_BY_ID_QUERY = gql` ${SHIPMENT_FIELDS_FRAGMENT} @@ -123,7 +137,6 @@ function ShipmentView() { onClose: onShipmentOverlayClose, onOpen: onShipmentOverlayOpen, } = useDisclosure(); - // State to show minus button near boxes when remove button is triggered const [showRemoveIcon, setShowRemoveIcon] = useState(false); const [shipmentState, setShipmentState] = useState(); @@ -362,10 +375,32 @@ function ShipmentView() { const shipmentContents = (data?.shipment?.details.filter((item) => item.removedOn === null) ?? []) as ShipmentDetail[]; + const changesLabel = (history: any): string => { + let changes = ""; + if ( + [ + ShipmentActionEvent.ShipmentCanceled, + ShipmentActionEvent.ShipmentCompleted, + ShipmentActionEvent.ShipmentSent, + ShipmentActionEvent.ShipmentStartReceiving, + ShipmentActionEvent.ShipmentStarted, + ].includes(history.action) + ) { + changes = `Shipment is ${history.action.toLowerCase().replace("shipment", "")} by ${history + .createdBy?.name}`; + } else { + changes = `Box ${history.box} is ${history.action + .toLowerCase() + .replace("box", "")} by ${history.createdBy?.name}`; + } + + return changes; + }; + const generateShipmentHistory = ( entry: Partial>, - ): IShipmentHistory[] => { - const shipmentHistory: IShipmentHistory[] = []; + ): ITimelineEntry[] => { + const shipmentHistory: ITimelineEntry[] = []; Object.entries(entry).forEach(([action, shipmentObj]) => { if (shipmentObj.createdOn) { @@ -380,7 +415,7 @@ function ShipmentView() { return shipmentHistory; }; - const shipmentLogs: IShipmentHistory[] = generateShipmentHistory({ + const shipmentLogs: ITimelineEntry[] = generateShipmentHistory({ [ShipmentActionEvent.ShipmentStarted]: { createdOn: shipmentData?.startedOn, createdBy: shipmentData?.startedBy! as User, @@ -403,10 +438,7 @@ function ShipmentView() { }, }); - // map over each ShipmentDetail to compile its history records - const shipmentDetailLogs: IShipmentHistory[] = ( - data?.shipment?.details! as ShipmentDetail[] - )?.flatMap((detail) => + const shipmentDetailLogs = (data?.shipment?.details! as ShipmentDetail[])?.flatMap((detail) => _.compact([ detail?.createdBy && { box: detail.box.labelIdentifier, @@ -433,29 +465,30 @@ function ShipmentView() { createdOn: new Date(detail?.receivedOn), }, ]), - ) as unknown as IShipmentHistory[]; + ); const allLogs = _.orderBy( _.sortBy(_.concat([...(shipmentLogs || []), ...(shipmentDetailLogs || [])]), "createdOn"), ["createdOn"], ["asc", "desc"], ); - const groupedHistoryEntries: _.Dictionary = _.groupBy( + const groupedHistoryEntries: _.Dictionary = _.groupBy( allLogs, (log) => `${formatDateKey(log?.createdOn)}`, ); - // sort each array of history entries in descending order const sortedGroupedHistoryEntries = _(groupedHistoryEntries) .toPairs() .map(([date, entries]) => ({ date, - entries: _.orderBy(entries, (entry) => new Date(entry?.createdOn), "desc"), + entries: _.orderBy(entries, (entry) => new Date(entry?.createdOn), "desc").map((entry) => ({ + ...entry, + action: changesLabel(entry), + })), })) .orderBy((entry) => new Date(entry.date), "desc") .value(); - // variables for loading dynamic components let shipmentTitle = View Shipment; let shipmentTab; let shipmentCard; @@ -465,7 +498,6 @@ function ShipmentView() { let canLooseShipment = false; let shipmentActionButtons = ; - // error and loading handling if (error) { shipmentTab = ( @@ -484,7 +516,6 @@ function ShipmentView() { (b) => b.id === data?.shipment?.sourceBase?.id, ) !== "undefined"; - // Role Sender // Different State UI Changes if (ShipmentState.Preparing === shipmentState && isSender) { canUpdateShipment = true; canCancelShipment = true; @@ -492,9 +523,7 @@ function ShipmentView() { shipmentTitle = Prepare Shipment; } else if (ShipmentState.Sent === shipmentState && isSender) { canLooseShipment = true; - } - // Role Receiver // Different State UI Changes - else if (ShipmentState.Sent === shipmentState && !isSender) { + } else if (ShipmentState.Sent === shipmentState && !isSender) { canLooseShipment = true; } else if (ShipmentState.Receiving === shipmentState && !isSender) { canLooseShipment = true; diff --git a/react/src/views/Transfers/ShipmentView/components/ShipmentHistory.tsx b/react/src/views/Transfers/ShipmentView/components/ShipmentHistory.tsx deleted file mode 100644 index 6582595c3..000000000 --- a/react/src/views/Transfers/ShipmentView/components/ShipmentHistory.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Box, Flex, Text } from "@chakra-ui/react"; -import { IGroupedHistoryEntry, IShipmentHistory, ShipmentActionEvent } from "./ShipmentTabs"; -import TimelineEntry from "./TimelineEntry"; - -export interface IShipmentHistoryProps { - histories: IGroupedHistoryEntry[]; -} - -function ShipmentHistory({ histories }: IShipmentHistoryProps) { - const changesLabel = (history: IShipmentHistory): string => { - let changes = ""; - if ( - [ - ShipmentActionEvent.ShipmentCanceled, - ShipmentActionEvent.ShipmentCompleted, - ShipmentActionEvent.ShipmentSent, - ShipmentActionEvent.ShipmentStartReceiving, - ShipmentActionEvent.ShipmentStarted, - ].includes(history.action) - ) { - changes = `Shipment is ${history.action.toLowerCase().replace("shipment", "")} by ${ - history.createdBy?.name - }`; - } else { - changes = `Box ${history.box} is ${history.action.toLowerCase().replace("box", "")} by ${ - history.createdBy?.name - }`; - } - - return changes; - }; - - function formatTime(date: Date): string { - const hours = date.getHours().toString().padStart(2, "0"); - const minutes = date.getMinutes().toString().padStart(2, "0"); - return `${hours}:${minutes}`; - } - - return ( - - {histories.map(({ date, entries }, index) => ( - - - - {date} - - - - {entries?.map((entry, indx) => ( - - ))} - - - ))} - - ); -} - -export default ShipmentHistory; diff --git a/react/src/views/Transfers/ShipmentView/components/ShipmentTabs.tsx b/react/src/views/Transfers/ShipmentView/components/ShipmentTabs.tsx index 30e98f918..545d93a26 100644 --- a/react/src/views/Transfers/ShipmentView/components/ShipmentTabs.tsx +++ b/react/src/views/Transfers/ShipmentView/components/ShipmentTabs.tsx @@ -1,8 +1,8 @@ import { TabList, TabPanels, Tabs, TabPanel, Tab, Center } from "@chakra-ui/react"; +import ShipmentHistory, { IGroupedRecordEntry } from "components/Timeline/Timeline"; import _ from "lodash"; import { Box, ShipmentDetail, ShipmentState, User } from "types/generated/graphql"; import ShipmentContent, { IShipmentContent } from "./ShipmentContent"; -import ShipmentHistory from "./ShipmentHistory"; // eslint-disable-next-line no-shadow export enum ShipmentActionEvent { @@ -32,7 +32,7 @@ export interface IGroupedHistoryEntry { export interface IShipmentTabsProps { shipmentState: ShipmentState | undefined; detail: ShipmentDetail[]; - histories: IGroupedHistoryEntry[]; + histories: IGroupedRecordEntry[]; isLoadingMutation: boolean | undefined; showRemoveIcon: Boolean; onRemoveBox: (id: string) => void; @@ -62,7 +62,7 @@ function ShipmentTabs({ size: group[0]?.sourceSize, numberOfItems: shipment.sourceQuantity, product: group[0]?.sourceProduct, - } as Box), + }) as Box, ), })) .orderBy((value) => value.totalLosts, "asc") @@ -95,7 +95,7 @@ function ShipmentTabs({ /> - +