Skip to content

Commit 6a28e85

Browse files
Add expiration date filter, sorting and expiry badges to inventory list
Move the inventory list filters onto MultiFilter, adding a product expiration date range filter with Expired and Expiring soon presets derived from the configured expiry restriction. Date presets may now be open ended so a preset can bound only one end of the range. Also supports sorting by expiration date and flags expired or expiring stock in the table. Co-authored-by: Cursor <cursoragent@cursor.com> Entire-Checkpoint: b16eb5dd7780
1 parent 16bf18a commit 6a28e85

6 files changed

Lines changed: 298 additions & 90 deletions

File tree

src/Utils/inventory.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { addMonths, endOfMonth, formatDate, isAfter } from "date-fns";
1+
import {
2+
addMonths,
3+
endOfMonth,
4+
formatDate,
5+
isAfter,
6+
startOfMonth,
7+
} from "date-fns";
28

39
import { Badge } from "@/components/ui/badge";
410
import { InventoryRead } from "@/types/inventory/product/inventory";
@@ -33,6 +39,30 @@ export function getExpiryStatus(
3339
return "valid";
3440
}
3541

42+
/**
43+
* Gets the expiration date range of products considered "expired" by
44+
* {@link getExpiryStatus}.
45+
*/
46+
export function getExpiredDateRange() {
47+
return { to: endOfMonth(new Date()) };
48+
}
49+
50+
/**
51+
* Gets the expiration date range of products considered "expiring_soon" by
52+
* {@link getExpiryStatus}, or `null` if no such range exists, i.e. when the
53+
* expiry restriction is disabled or covers only the current month.
54+
*/
55+
export function getExpiringSoonDateRange() {
56+
const expiryMonthOffset = careConfig.inventory.expiryMonthOffset;
57+
if (!expiryMonthOffset) return null;
58+
59+
const today = new Date();
60+
return {
61+
from: startOfMonth(addMonths(today, 1)),
62+
to: endOfMonth(addMonths(today, expiryMonthOffset)),
63+
};
64+
}
65+
3666
/**
3767
* Checks if a product is restricted based on its expiration date
3868
* (i.e., expired or expiring soon)

src/components/ui/multi-filter/dateFilter.tsx

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
FilterConfig,
2626
FilterDateRange,
2727
FilterValues,
28+
isSameDateRange,
2829
longDateRangeOptions,
2930
} from "./utils/Utils";
3031

@@ -169,7 +170,7 @@ function DateRangeOptions({
169170
isCustomDateRangeSelected: boolean;
170171
filter: FilterConfig;
171172
handleDateRangeSelect: (option: DateRangeOption) => void;
172-
isSameRange: (option: DateRangeOption) => boolean | undefined;
173+
isSameRange: (option: DateRangeOption) => boolean;
173174
}) {
174175
const { t } = useTranslation();
175176
const [focusItemRef, setFocusItemRef] = useState<HTMLDivElement | null>(null);
@@ -259,12 +260,8 @@ export default function RenderDateFilter({
259260
setDateTo(date?.to);
260261
};
261262

262-
const isSameRange = (option: DateRangeOption) => {
263-
const { from, to } = option.getDateRange();
264-
return (
265-
dateFrom && isSameDay(dateFrom, from) && dateTo && isSameDay(dateTo, to)
266-
);
267-
};
263+
const isSameRange = (option: DateRangeOption) =>
264+
isSameDateRange({ from: dateFrom, to: dateTo }, option.getDateRange());
268265

269266
const isCustomDateRangeSelected =
270267
selected.from || selected.to
@@ -335,15 +332,8 @@ export const SelectedDateBadge = ({
335332
selected.from && selected.to && isSameDay(selected.from, selected.to);
336333
const presentDate = isSameDate ? selected.from : selected.from || selected.to;
337334

338-
const isSameRange = (option: DateRangeOption) => {
339-
const { from, to } = option.getDateRange();
340-
return (
341-
selected.from &&
342-
isSameDay(selected.from, from) &&
343-
selected.to &&
344-
isSameDay(selected.to, to)
345-
);
346-
};
335+
const isSameRange = (option: DateRangeOption) =>
336+
isSameDateRange(selected, option.getDateRange());
347337

348338
const isRangeSelected = (filter.meta as DateFilterMeta)?.presetOptions?.find(
349339
(option) => isSameRange(option),

src/components/ui/multi-filter/filterConfigs.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
Operation,
3838
createFilterConfig,
3939
getVariantColorClasses,
40+
isSameDateRange,
4041
} from "./utils/Utils";
4142

4243
import { SelectedFacilityUserBadge } from "@/components/ui/multi-filter/facilityUserFilter";
@@ -71,10 +72,19 @@ import {
7172
MEDICATION_DISPENSE_STATUS_COLORS,
7273
MedicationDispenseStatus,
7374
} from "@/types/emr/medicationDispense/medicationDispense";
75+
import {
76+
INVENTORY_STATUS_COLORS,
77+
InventoryStatus,
78+
InventoryStatusOptions,
79+
} from "@/types/inventory/product/inventory";
7480
import {
7581
REQUEST_ORDER_PRIORITY_COLORS,
7682
RequestOrderPriority,
7783
} from "@/types/inventory/requestOrder/requestOrder";
84+
import {
85+
getExpiredDateRange,
86+
getExpiringSoonDateRange,
87+
} from "@/Utils/inventory";
7888
import careConfig from "@careConfig";
7989
import { Zap } from "lucide-react";
8090
export const encounterStatusFilter = (
@@ -189,6 +199,7 @@ export const dateFilter = (
189199
label?: string,
190200
dateRangeOptions?: DateRangeOption[],
191201
disableClear?: boolean,
202+
getOperations: (selected: FilterDateRange) => Operation[] = getDateOperations,
192203
) =>
193204
createFilterConfig(key, label || t("started_date"), "date", [], {
194205
renderSelected: (
@@ -205,7 +216,7 @@ export const dateFilter = (
205216
);
206217
},
207218
getOperations: (selected: FilterValues) =>
208-
getDateOperations(selected as FilterDateRange),
219+
getOperations(selected as FilterDateRange),
209220
mode: "single",
210221
icon: <CalendarFold className="w-4 h-4" />,
211222
dateRangeOptions,
@@ -684,6 +695,70 @@ export const inventoryPriorityFilter = (
684695
},
685696
);
686697

698+
export const inventoryStatusFilter = (
699+
key: string = "status",
700+
mode: FilterMode = "single",
701+
customOperations?: Operation[],
702+
) =>
703+
createFilterConfig(
704+
key,
705+
t("status"),
706+
"command",
707+
InventoryStatusOptions.map((value) => ({
708+
value: value,
709+
label: t(value),
710+
color: getVariantColorClasses(INVENTORY_STATUS_COLORS[value]),
711+
})),
712+
{
713+
renderSelected: (selected: FilterValues) => {
714+
const selectedStatus = selected as string[];
715+
if (typeof selectedStatus[0] === "string") {
716+
const option = selectedStatus[0];
717+
const variant = INVENTORY_STATUS_COLORS[option as InventoryStatus];
718+
return (
719+
<GenericSelectedBadge
720+
selectedValue={option}
721+
selectedLength={selectedStatus.length}
722+
variant={variant}
723+
/>
724+
);
725+
}
726+
return <></>;
727+
},
728+
getOperations: () => customOperations || [{ label: "is" }],
729+
mode,
730+
icon: <CircleDashed className="size-4" />,
731+
showColorIndicators: true,
732+
},
733+
);
734+
735+
export const productExpirationDateFilter = (
736+
key: string = "product_expiration_date",
737+
label?: string,
738+
) => {
739+
const expiringSoonRange = getExpiringSoonDateRange();
740+
741+
const presetOptions: DateRangeOption[] = [
742+
{ label: "expired", getDateRange: getExpiredDateRange },
743+
...(expiringSoonRange
744+
? [{ label: "expiring_soon", getDateRange: () => expiringSoonRange }]
745+
: []),
746+
];
747+
748+
return dateFilter(
749+
key,
750+
label || t("expiration_date"),
751+
presetOptions,
752+
undefined,
753+
(selected) =>
754+
presetOptions.some((option) =>
755+
isSameDateRange(selected, option.getDateRange()),
756+
)
757+
? [{ label: "is" }]
758+
: getDateOperations(selected),
759+
);
760+
};
761+
687762
export const dispenseStatusFilter = (
688763
key: string = "dispense_status",
689764
mode: FilterMode = "single",

src/components/ui/multi-filter/utils/Utils.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import React from "react";
22

3-
import { addDays, subDays, subMonths, subWeeks, subYears } from "date-fns";
3+
import {
4+
addDays,
5+
isSameDay,
6+
subDays,
7+
subMonths,
8+
subWeeks,
9+
subYears,
10+
} from "date-fns";
411

512
import { ActivityDefinitionFilterValue } from "@/components/ui/multi-filter/activityDefinitionFilter";
613
import { GenericSelectedBadge } from "@/components/ui/multi-filter/genericFilter";
@@ -153,10 +160,20 @@ export interface FilterDateRange {
153160

154161
export interface DateRangeOption {
155162
label: string;
156-
getDateRange: () => { from: Date; to: Date };
163+
/** Ranges may be open ended, i.e. bound on only one end. */
164+
getDateRange: () => FilterDateRange;
157165
count?: number;
158166
}
159167

168+
const isSameBoundary = (a?: Date, b?: Date) =>
169+
a && b ? isSameDay(a, b) : !a && !b;
170+
171+
/**
172+
* Compares two (possibly open ended) date ranges with a day's precision.
173+
*/
174+
export const isSameDateRange = (a: FilterDateRange, b: FilterDateRange) =>
175+
isSameBoundary(a.from, b.from) && isSameBoundary(a.to, b.to);
176+
160177
export type Operation = {
161178
value?: string;
162179
label: string;

0 commit comments

Comments
 (0)