diff --git a/public/locale/en.json b/public/locale/en.json index 7a97ce914fa..37b139969cd 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -6583,6 +6583,7 @@ "view_patients": "View Patients", "view_payments": "View Payments", "view_prescriptions": "View Prescriptions", + "view_product_details": "View product details", "view_profile": "View Profile", "view_real_time_bed_status": "View real-time bed status across all wards", "view_report": "View Report", diff --git a/src/components/Common/ResourceDefinitionCategoryPicker.tsx b/src/components/Common/ResourceDefinitionCategoryPicker.tsx index 2bc5a17e050..31699930ddf 100644 --- a/src/components/Common/ResourceDefinitionCategoryPicker.tsx +++ b/src/components/Common/ResourceDefinitionCategoryPicker.tsx @@ -808,9 +808,11 @@ export function ResourceDefinitionCategoryPicker({ variant="outline" role="combobox" aria-expanded={open} + type="button" className={cn( "justify-between px-3 py-2 w-full shadow-xs border border-gray-300 font-medium h-auto min-h-9", disabled && "opacity-50 cursor-not-allowed", + value && !shouldHideClearButton && "rounded-r-none", className, )} disabled={disabled} @@ -829,8 +831,9 @@ export function ResourceDefinitionCategoryPicker({ {value && !shouldHideClearButton && ( e.stopPropagation()} > - {inventory.product.product_knowledge.name} + + {t("view_product_details")} + + + { + setShowAllDeliveries(open); + if (!open) setSelectedProductKnowledgeDrawer(undefined); + }} + > + + + {t("all_deliveries")} + + + + ); } diff --git a/src/pages/Facility/services/inventory/SupplyDeliveryTable.tsx b/src/pages/Facility/services/inventory/SupplyDeliveryTable.tsx index e48de511eaa..ef76a41fcaa 100644 --- a/src/pages/Facility/services/inventory/SupplyDeliveryTable.tsx +++ b/src/pages/Facility/services/inventory/SupplyDeliveryTable.tsx @@ -63,6 +63,8 @@ interface SupplyDeliveryTableProps { isRequester?: boolean; facilityId?: string; linkToProduct?: boolean; + showLocations?: boolean; + serialNumberOffset?: number; } export function SupplyDeliveryTable({ @@ -78,6 +80,8 @@ export function SupplyDeliveryTable({ isRequester = false, facilityId, linkToProduct = false, + showLocations = false, + serialNumberOffset = 0, }: SupplyDeliveryTableProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -157,7 +161,7 @@ export function SupplyDeliveryTable({ // Build a map of delivery id -> serial number for non-cancelled deliveries const serialNumberMap = useMemo(() => { const map = new Map(); - let serial = 1; + let serial = serialNumberOffset + 1; for (const delivery of deliveries) { if ( ACTIVE_SUPPLY_DELIVERY_STATUSES.includes( @@ -168,7 +172,7 @@ export function SupplyDeliveryTable({ } } return map; - }, [deliveries]); + }, [deliveries, serialNumberOffset]); return ( @@ -190,6 +194,12 @@ export function SupplyDeliveryTable({ {t("#")}{t("item")}{t("batch")} + {showLocations && ( + <> + {t("origin")} + {t("destination")} + + )} {t("requested_qty")} {!internal && {t("pack_size")}} {!internal && {t("pack_qty")}} @@ -255,10 +265,13 @@ export function SupplyDeliveryTable({ onClick={() => onDeliveryClick?.(delivery)} > {(() => { - const productId = internal + const internalDelivery = + internal || !!delivery.supplied_inventory_item?.product?.id; + + const productId = internalDelivery ? delivery.supplied_inventory_item?.product?.id : delivery.supplied_item?.id; - const productName = internal + const productName = internalDelivery ? delivery.supplied_inventory_item?.product?.product_knowledge ?.name : delivery.supplied_item?.product_knowledge?.name; @@ -286,6 +299,19 @@ export function SupplyDeliveryTable({ {delivery.supplied_inventory_item?.product?.batch?.lot_number || "-"} + {showLocations && ( + <> + + {delivery?.order?.origin?.name || + delivery?.order?.supplier?.name || + delivery?.order?.patient?.name || + "-"} + + + {delivery?.order?.destination?.name || "-"} + + + )} {delivery.supply_request ? round(delivery.supply_request.quantity) diff --git a/src/types/inventory/requestOrder/requestOrder.ts b/src/types/inventory/requestOrder/requestOrder.ts index 5e3086b2368..e0a9618bc36 100644 --- a/src/types/inventory/requestOrder/requestOrder.ts +++ b/src/types/inventory/requestOrder/requestOrder.ts @@ -1,4 +1,5 @@ import { Badge } from "@/components/ui/badge"; +import { PatientListRead } from "@/types/emr/patient/patient"; import { TagConfig } from "@/types/emr/tagConfig/tagConfig"; import { LocationDetail } from "@/types/location/location"; import { Organization } from "@/types/organization/organization"; @@ -90,4 +91,5 @@ export interface RequestOrderRetrieve extends RequestOrder { destination: LocationDetail; supplier?: Organization; tags: TagConfig[]; + patient?: PatientListRead; } diff --git a/src/types/inventory/supplyDelivery/supplyDelivery.ts b/src/types/inventory/supplyDelivery/supplyDelivery.ts index 742d8f0e673..6ef0924a802 100644 --- a/src/types/inventory/supplyDelivery/supplyDelivery.ts +++ b/src/types/inventory/supplyDelivery/supplyDelivery.ts @@ -1,5 +1,6 @@ import { InventoryRead } from "@/types/inventory/product/inventory"; import { ProductRead } from "@/types/inventory/product/product"; +import { RequestOrderRetrieve } from "@/types/inventory/requestOrder/requestOrder"; import { SupplyRequestRead } from "@/types/inventory/supplyRequest/supplyRequest"; export enum SupplyDeliveryStatus { @@ -79,4 +80,5 @@ export interface SupplyDeliveryRead extends SupplyDeliveryBase { modified_date?: string; supply_request?: SupplyRequestRead; extensions: Record; + order?: RequestOrderRetrieve; } diff --git a/tests/facility/services/locations/inventory/allDeliveries.spec.ts b/tests/facility/services/locations/inventory/allDeliveries.spec.ts new file mode 100644 index 00000000000..9b8bb1dc1ad --- /dev/null +++ b/tests/facility/services/locations/inventory/allDeliveries.spec.ts @@ -0,0 +1,157 @@ +import { expect, test } from "@playwright/test"; +import { getFacilityId } from "tests/support/facilityId"; + +// Use the authenticated (admin) state +test.use({ storageState: "tests/.auth/user.json" }); + +const PRODUCT_NAME = "Paracetamol"; + +test.describe("Inventory Summary — All Deliveries drawer", () => { + let pharmacyBasePath: string; + + test.beforeEach(async ({ page }) => { + const facilityId = getFacilityId(); + + // Reach the seeded "Main Pharmacy" location, which the fixtures stock with + // products (Amoxicillin, Paracetamol, Ibuprofen, Gloves) via completed + // supply deliveries destined to it. + await page.goto(`/facility/${facilityId}/services/`); + await page.getByRole("link", { name: "Main Pharmacy" }).click(); + await page.getByRole("link", { name: "Pharmacy" }).click(); + + const pharmacyLocationId = + page + .url() + .match( + new RegExp( + `/facility/${facilityId}/locations/([^/]+)/medication_requests`, + ), + )?.[1] ?? ""; + expect(pharmacyLocationId).not.toBe(""); + pharmacyBasePath = `/facility/${facilityId}/locations/${pharmacyLocationId}`; + + await page.goto(`${pharmacyBasePath}/inventory/summary`); + await expect( + page.getByRole("heading", { name: "Inventory" }), + ).toBeVisible(); + }); + + test("opens the drawer and lists deliveries for a stocked product", async ({ + page, + }) => { + await test.step("Wait for the inventory row to render", async () => { + await expect( + page.getByRole("button", { name: PRODUCT_NAME }), + ).toBeVisible(); + }); + + await test.step("Open the All Deliveries drawer and assert its API call", async () => { + const deliveriesResponse = page.waitForResponse( + (resp) => + resp.url().includes("/api/v1/supply_delivery/") && + resp.request().method() === "GET" && + resp.url().includes("supplied_inventory_item_product_knowledge=") && + resp.ok(), + ); + await page.getByRole("button", { name: PRODUCT_NAME }).click(); + await deliveriesResponse; + }); + + await test.step("Verify the deliveries table is shown in the drawer", async () => { + const drawer = page.locator('[data-slot="drawer-content"]'); + await expect(drawer.getByText("All Deliveries")).toBeVisible(); + // The delivered product renders as a link inside the deliveries table — + // its presence proves a delivery row was rendered (not the empty state). + await expect( + drawer.getByRole("link", { name: PRODUCT_NAME }), + ).toBeVisible(); + }); + }); + + test("shows the empty state in the drawer when a product has no deliveries", async ({ + page, + }) => { + await test.step("Wait for the inventory row to render", async () => { + await expect( + page.getByRole("button", { name: PRODUCT_NAME }), + ).toBeVisible(); + }); + + // The empty state is a defensive UI branch: every product surfaced on the + // summary necessarily has at least one delivery, so it is unreachable with + // seeded data. Stub the deliveries list as empty to exercise that branch. + await test.step("Stub the deliveries response as empty", async () => { + await page.route( + (url) => + url.pathname === "/api/v1/supply_delivery/" && + url.searchParams.has("supplied_inventory_item_product_knowledge"), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + count: 0, + results: [], + next: null, + previous: null, + }), + }), + ); + }); + + await test.step("Open the drawer and verify the empty state", async () => { + await page.getByRole("button", { name: PRODUCT_NAME }).click(); + const drawer = page.locator('[data-slot="drawer-content"]'); + await expect(drawer.getByText("All Deliveries")).toBeVisible(); + await expect(drawer.getByText("No delivery found")).toBeVisible(); + }); + }); + + test("paginates the deliveries within the drawer", async ({ page }) => { + await test.step("Wait for the inventory row to render", async () => { + await expect( + page.getByRole("button", { name: PRODUCT_NAME }), + ).toBeVisible(); + }); + + // Seeded pharmacy products have a single delivery, so the pagination + // controls are otherwise hidden. Keep the real (valid) delivery rows but + // inflate the reported count past DELIVERIES_PER_PAGE (10) so a second page + // exists and the controls render. + await test.step("Inflate the deliveries count to expose pagination", async () => { + await page.route( + (url) => + url.pathname === "/api/v1/supply_delivery/" && + url.searchParams.has("supplied_inventory_item_product_knowledge"), + async (route) => { + const response = await route.fetch(); + const json = await response.json(); + json.count = 15; + await route.fulfill({ response, json }); + }, + ); + }); + + await test.step("Open the drawer and confirm the first page renders", async () => { + await page.getByRole("button", { name: PRODUCT_NAME }).click(); + const drawer = page.locator('[data-slot="drawer-content"]'); + await expect(drawer.getByText("All Deliveries")).toBeVisible(); + await expect( + drawer.getByRole("link", { name: PRODUCT_NAME }), + ).toBeVisible(); + }); + + await test.step("Navigate to page 2 and assert the offset advances", async () => { + const drawer = page.locator('[data-slot="drawer-content"]'); + const page2Response = page.waitForResponse( + (resp) => + resp.url().includes("/api/v1/supply_delivery/") && + resp.request().method() === "GET" && + resp.url().includes("offset=10") && + resp.ok(), + ); + await drawer.getByRole("button", { name: "2", exact: true }).click(); + await page2Response; + }); + }); +});