diff --git a/.example.env b/.example.env index a42ab852082..2b23c68d46a 100644 --- a/.example.env +++ b/.example.env @@ -198,5 +198,9 @@ REACT_DECIMAL_ROUNDING_METHOD=ROUND_HALF_UP # Maximum number of forms that can be favorited in the forms dialog (default: 5) REACT_MAX_FORM_DIALOG_FAVORITES=5 +# Maximum number of datapoints allowed in a single upsert request (default: 100) +# Must not exceed the backend limit +REACT_MAX_DATAPOINTS_PER_UPSERT=100 + # Default tab for medication selector. Valid values: product, valueset REACT_MEDICATION_VALUE_SET_SELECT_DEFAULT_TAB=product diff --git a/care.config.ts b/care.config.ts index 58e1fd2a927..f5c211578c7 100644 --- a/care.config.ts +++ b/care.config.ts @@ -421,6 +421,14 @@ const careConfig = { maxFormDialogFavorites: env.REACT_MAX_FORM_DIALOG_FAVORITES ? parseInt(env.REACT_MAX_FORM_DIALOG_FAVORITES, 10) : 5, + + /** + * Maximum number of datapoints allowed in a single upsert request. + * This should be set with whatever backend sets. + */ + maxDatapointsPerUpsert: env.REACT_MAX_DATAPOINTS_PER_UPSERT + ? parseInt(env.REACT_MAX_DATAPOINTS_PER_UPSERT, 10) + : 100, } as const; export default careConfig; diff --git a/public/locale/en.json b/public/locale/en.json index 55ccda61d9e..6430e17ff12 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -3107,6 +3107,7 @@ "issuer_type": "Issuer Type", "item": "Item", "item_condition": "Item Condition?", + "item_limit_reached": "Item limit reached", "item_location": "Item Location", "item_marked_as_abandoned": "Item marked as abandoned successfully", "item_marked_as_entered_in_error": "Item marked as entered in error successfully", @@ -3227,7 +3228,7 @@ "live": "Live", "live_monitoring": "Live Monitoring", "live_patients_total_beds": "Live Patients / Total beds", - "load_from_order": "Load from order", + "load_from_order_with_items": "Load from order ({{count}} items)", "load_more": "Load More", "loading": "Loading...", "loading_appointment_details": "Loading appointment details...", @@ -3457,6 +3458,7 @@ "max": "Max", "max_applicable_discounts": "Maximum Applicable Discounts", "max_applicable_discounts_description": "The maximum number of discount components that can be applied to a single invoice. Set to 0 for no discount.", + "max_datapoints_per_upsert_limit": "You cannot add more than {{count}} items in a single delivery.", "max_dosage_24_hrs": "Max. dosage in 24 hrs.", "max_dosage_in_24hrs_gte_base_dosage_error": "Max. dosage in 24 hours must be greater than or equal to base dosage", "max_favorites_reached": "You've reached the limit. Only {{count}} forms can be favourited.", diff --git a/scripts/validate-env.ts b/scripts/validate-env.ts index f0ad002ce00..8b0cf06b231 100644 --- a/scripts/validate-env.ts +++ b/scripts/validate-env.ts @@ -178,6 +178,7 @@ const envSchema = z message: `Must be one of: ${VALID_ROUNDING_METHODS.join(", ")}`, }) .optional(), + REACT_MAX_DATAPOINTS_PER_UPSERT: numberAsString.optional(), REACT_MAX_FORM_DIALOG_FAVORITES: positiveNumberAsString.optional(), }) .superRefine(async (data, ctx) => { diff --git a/src/CAREUI/display/Callout.tsx b/src/CAREUI/display/Callout.tsx index c14a471be7d..2fc727cd04b 100644 --- a/src/CAREUI/display/Callout.tsx +++ b/src/CAREUI/display/Callout.tsx @@ -1,11 +1,11 @@ -import React from "react"; +import React, { ReactNode } from "react"; import { cn } from "@/lib/utils"; interface CalloutProps { variant?: "primary" | "secondary" | "warning" | "alert" | "danger"; className?: string; - badge: string; + badge: ReactNode; children: React.ReactNode; } @@ -27,20 +27,7 @@ export default function Callout({ props.className, )} > -
- {props.badge} -
+ {props.badge}
{props.children}
diff --git a/src/pages/Facility/services/inventory/externalSupply/deliveryOrder/AddSupplyDeliveryForm.tsx b/src/pages/Facility/services/inventory/externalSupply/deliveryOrder/AddSupplyDeliveryForm.tsx index 9ae9ececd46..34824173509 100644 --- a/src/pages/Facility/services/inventory/externalSupply/deliveryOrder/AddSupplyDeliveryForm.tsx +++ b/src/pages/Facility/services/inventory/externalSupply/deliveryOrder/AddSupplyDeliveryForm.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { PlusCircle, Trash2 } from "lucide-react"; +import { PlusCircle, Trash2, TriangleAlert } from "lucide-react"; import { useQueryParams } from "raviger"; import { useCallback, useMemo, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; @@ -43,6 +43,7 @@ import { TableRow, } from "@/components/ui/table"; +import Callout from "@/CAREUI/display/Callout"; import { getExtensionFieldsWithName, processExtensions, @@ -129,6 +130,8 @@ interface Props { origin?: string; destination: string; onSuccess: () => void; + supplyDeliveriesCount: number; + isFetchingSupplyDeliveries: boolean; } export function AddSupplyDeliveryForm({ @@ -137,6 +140,8 @@ export function AddSupplyDeliveryForm({ origin, destination, onSuccess, + supplyDeliveriesCount, + isFetchingSupplyDeliveries, }: Props) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -237,6 +242,12 @@ export function AddSupplyDeliveryForm({ name: "items", }); + const hasReachedUpsertLimit = + supplyDeliveriesCount >= careConfig.maxDatapointsPerUpsert; + + const disableAddItem = + isProcessing || hasReachedUpsertLimit || isFetchingSupplyDeliveries; + const loadFromSupplyRequests = () => { setIsSelectDialogOpen(true); handleSelectAll(true); @@ -269,6 +280,7 @@ export function AddSupplyDeliveryForm({ noOptionsMessage={t("no_orders_found")} className="px-10" popoverContentClassName="w-auto" + disabled={disableAddItem} /> ); @@ -897,29 +909,47 @@ export function AddSupplyDeliveryForm({ -
- - {supplyRequests?.results?.length && - supplyRequests?.results?.length > 0 && ( + + {t("max_datapoints_per_upsert_limit", { + count: careConfig.maxDatapointsPerUpsert, + })} + + + ) : ( +
+ + {!!supplyRequests?.results?.length && ( )} -
+
+ )}

- {t("or")} -

@@ -971,6 +1001,7 @@ export function AddSupplyDeliveryForm({ type="button" variant="outline_primary" onClick={() => handleAddAnotherItem()} + disabled={disableAddItem} > {t("add_item")} @@ -1007,7 +1038,7 @@ export function AddSupplyDeliveryForm({ {t("select_all")}
-
+
{supplyRequests.results.map((request) => (
delivery.supply_request && delivery.supply_request.id, @@ -465,12 +471,27 @@ export function DeliveryOrderShow({ (delivery) => delivery.status === SupplyDeliveryStatus.completed, ) ?? false; + const allSupplyDeliveriesCompletedOrAbandoned = + !!supplyDeliveries?.results?.length && + supplyDeliveries.results.every( + (delivery) => + delivery.status === SupplyDeliveryStatus.completed || + delivery.status === SupplyDeliveryStatus.abandoned, + ); + const deliveryOrderStatusActions = getDeliveryOrderStatusActions( deliveryOrder.status, internal, anyCompletedSupplyDeliveries, ); + const hasReachedUpsertLimit = + supplyDeliveries && + supplyDeliveries.results.length >= careConfig.maxDatapointsPerUpsert; + + const isWithinUpsertLimit = + selectedDeliveries.length <= careConfig.maxDatapointsPerUpsert; + return ( {isUpdating ? t("updating") : t("mark_as_completed")} @@ -799,7 +821,8 @@ export function DeliveryOrderShow({ disabled={ isUpdating || isUpsertingDeliveries || - selectedDeliveries.length === 0 + selectedDeliveries.length === 0 || + !isWithinUpsertLimit } > {isUpsertingDeliveries @@ -819,7 +842,8 @@ export function DeliveryOrderShow({ disabled={ isUpdating || isUpsertingDeliveries || - selectedDeliveries.length === 0 + selectedDeliveries.length === 0 || + !isWithinUpsertLimit } > {t("mark_as_abandoned")} @@ -829,7 +853,8 @@ export function DeliveryOrderShow({ disabled={ isUpdating || isUpsertingDeliveries || - selectedDeliveries.length === 0 + selectedDeliveries.length === 0 || + !isWithinUpsertLimit } > {t("mark_as_damaged")} @@ -840,6 +865,23 @@ export function DeliveryOrderShow({ )}
+ {!isWithinUpsertLimit && ( +
+ + } + > + + {t("max_datapoints_per_upsert_limit", { + count: careConfig.maxDatapointsPerUpsert, + })} + + +
+ )} {isLoadingSupplyDeliveries ? (
@@ -911,14 +953,34 @@ export function DeliveryOrderShow({ <> )} + {hasReachedUpsertLimit && canAddSupplyDeliveries && ( + + } + > + + {t("max_datapoints_per_upsert_limit", { + count: careConfig.maxDatapointsPerUpsert, + })} + + + )} + {/* Add New Supply Delivery Form - Always show when in draft mode */} - {canAddSupplyDeliveries && ( + {canAddSupplyDeliveries && !hasReachedUpsertLimit && ( )}
diff --git a/src/pages/Facility/services/inventory/externalSupply/requestOrder/AddItemsForm.tsx b/src/pages/Facility/services/inventory/externalSupply/requestOrder/AddItemsForm.tsx index 74f96e4e765..0418ec13bed 100644 --- a/src/pages/Facility/services/inventory/externalSupply/requestOrder/AddItemsForm.tsx +++ b/src/pages/Facility/services/inventory/externalSupply/requestOrder/AddItemsForm.tsx @@ -5,6 +5,10 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import * as z from "zod"; +import careConfig from "@careConfig"; + +import Callout from "@/CAREUI/display/Callout"; + import { Table, TableBody, @@ -27,6 +31,7 @@ import { ProductKnowledgeSelect } from "@/pages/Facility/services/inventory/Prod import { EmptyState } from "@/components/ui/empty-state"; import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; import { ProductKnowledgeBase } from "@/types/inventory/productKnowledge/productKnowledge"; import { RequestOrderStatus } from "@/types/inventory/requestOrder/requestOrder"; import { SupplyRequestStatus } from "@/types/inventory/supplyRequest/supplyRequest"; @@ -34,7 +39,7 @@ import supplyRequestApi from "@/types/inventory/supplyRequest/supplyRequestApi"; import { zodDecimal } from "@/Utils/decimal"; import { ShortcutBadge } from "@/Utils/keyboardShortcutComponents"; import mutate from "@/Utils/request/mutate"; -import { Box, Check, Trash2 } from "lucide-react"; +import { Box, Check, Trash2, TriangleAlert } from "lucide-react"; const supplyRequestFormSchema = z.object({ requests: z.array( @@ -56,6 +61,8 @@ interface AddItemsFormProps { updateOrderStatus: (status: RequestOrderStatus) => void; disableApproveButton: boolean; showEmptyState: boolean; + + supplyRequestsCount: number; } export function AddItemsForm({ @@ -64,6 +71,7 @@ export function AddItemsForm({ updateOrderStatus, disableApproveButton, showEmptyState, + supplyRequestsCount, }: AddItemsFormProps) { const { t } = useTranslation(); @@ -79,6 +87,9 @@ export function AddItemsForm({ name: "requests", }); + const hasReachedUpsertLimit = + supplyRequestsCount + fields.length >= careConfig.maxDatapointsPerUpsert; + const { mutate: createSupplyRequests, isPending: isCreating } = useMutation({ mutationFn: async ( requests: Array<{ item: { id: string; name: string }; quantity: string }>, @@ -208,12 +219,28 @@ export function AddItemsForm({
)} - + {hasReachedUpsertLimit ? ( + + } + > + + {t("max_datapoints_per_upsert_limit", { + count: careConfig.maxDatapointsPerUpsert, + })} + + + ) : ( + + )} {fields.length > 0 ? ( <> @@ -239,7 +266,9 @@ export function AddItemsForm({ ) : (
-

-{t("or")}-

+

+ -{t("or")}- +

diff --git a/src/pages/Facility/services/inventory/externalSupply/requestOrder/RequestOrderShow.tsx b/src/pages/Facility/services/inventory/externalSupply/requestOrder/RequestOrderShow.tsx index c53145de7dd..8cf326fea3c 100644 --- a/src/pages/Facility/services/inventory/externalSupply/requestOrder/RequestOrderShow.tsx +++ b/src/pages/Facility/services/inventory/externalSupply/requestOrder/RequestOrderShow.tsx @@ -755,6 +755,9 @@ export function RequestOrderShow({ showEmptyState={ supplyRequests?.results.length === 0 } + supplyRequestsCount={ + supplyRequests?.results.length || 0 + } />

)} diff --git a/src/pages/Facility/services/pharmacy/components/AddMedicationReturnItemForm.tsx b/src/pages/Facility/services/pharmacy/components/AddMedicationReturnItemForm.tsx index dd953eb287f..c7b9c60794b 100644 --- a/src/pages/Facility/services/pharmacy/components/AddMedicationReturnItemForm.tsx +++ b/src/pages/Facility/services/pharmacy/components/AddMedicationReturnItemForm.tsx @@ -467,8 +467,9 @@ export function AddMedicationReturnItemForm({ variant="secondary" onClick={loadFromMedicationDispenses} > - {t("load_from_order")} ({medicationDispenses.length}{" "} - {t("items")}) + {t("load_from_order_with_items", { + count: medicationDispenses.length, + })} )} @@ -506,8 +507,9 @@ export function AddMedicationReturnItemForm({ variant="outline_primary" onClick={loadFromMedicationDispenses} > - {t("load_from_order")} ({medicationDispenses.length}{" "} - {t("items")}) + {t("load_from_order_with_items", { + count: medicationDispenses.length, + })}

- {t("or")} -

diff --git a/src/pages/Scheduling/components/CreateScheduleTemplateSheet.tsx b/src/pages/Scheduling/components/CreateScheduleTemplateSheet.tsx index 3f89a0dea17..906d31c3285 100644 --- a/src/pages/Scheduling/components/CreateScheduleTemplateSheet.tsx +++ b/src/pages/Scheduling/components/CreateScheduleTemplateSheet.tsx @@ -54,6 +54,7 @@ import { ScheduleAvailabilityCreateRequest, } from "@/types/scheduling/schedule"; import scheduleApis from "@/types/scheduling/scheduleApi"; +import { Info } from "lucide-react"; interface Props { facilityId: string; @@ -275,7 +276,10 @@ export default function CreateScheduleTemplateSheet({ if (!slotsPerSession || !tokenDuration) return null; return ( - + } + >
{template.is_public && !field.value && ( - + + } + >

{t("template_visibility_change_warning")}

@@ -636,7 +641,10 @@ const NewAvailabilityCard = ({ if (!slotsPerSession || !tokenDuration) return null; return ( - + } + > { await page .getByRole("row", { name: "Requested Qty." }) .getByRole("checkbox") - .click(); - await page.getByRole("button", { name: "Mark as Completed" }).click(); + .check(); + const receiveButton = page.getByRole("button", { + name: "Receive & Update Stock", + }); + await expect(receiveButton).toBeEnabled(); + await receiveButton.click(); + await page.getByRole("button", { name: "Confirm" }).click(); + const markAsCompletedButton = page.getByRole("button", { + name: "Mark as Completed", + }); + await expect(markAsCompletedButton).toBeEnabled(); + await markAsCompletedButton.click(); await page.goto(bioChembasePath + "/inventory/internal/receive"); await page.getByRole("tab", { name: "Incoming Deliveries" }).click();