Skip to content

Commit d5bb815

Browse files
authored
Merge branch 'ohcnetwork:develop' into develop
2 parents d65797c + 93bd6b4 commit d5bb815

39 files changed

Lines changed: 2180 additions & 1238 deletions

public/locale/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,6 +1946,8 @@
19461946
"duplicate_patient_record_confirmation": "Admit the patient record to your facility by adding the year of birth",
19471947
"duplicate_patient_record_rejection": "I confirm that the patient I want to create is not on the list.",
19481948
"duration": "Duration",
1949+
"duration_input_placeholder": "Type eg. 5 days, 2 weeks",
1950+
"duration_placeholder": "eg. 5 days",
19491951
"duration_unit": "Duration Unit",
19501952
"duration_unit_placeholder": "Select Duration Unit",
19511953
"duration_value": "Duration Value",
@@ -2552,6 +2554,8 @@
25522554
"found_patient_with_this_one": "Found <strong>{{count}} patient</strong> with this {{identifier}}.",
25532555
"found_patient_with_this_other": "Found <strong>{{count}} patients</strong> with this {{identifier}}.",
25542556
"frequency": "Frequency",
2557+
"frequency_input_placeholder": "Type eg. 1-0-1, SOS, Q6H",
2558+
"frequency_placeholder": "eg. 1-0-1",
25552559
"from": "from",
25562560
"from_package": "From Package",
25572561
"from_user": "from User",
@@ -3494,6 +3498,7 @@
34943498
"no_dosage_instrctions_available": "No dosage instructions available",
34953499
"no_drawings_so_far": "No drawings so far",
34963500
"no_duplicate_facility": "You should not create duplicate facilities",
3501+
"no_duration_found": "Type a number for suggestions",
34973502
"no_encounter_associated": "No encounter associated",
34983503
"no_encounter_history": "No encounter history available",
34993504
"no_encounter_linked": "No encounter linked to this appointment",
@@ -4658,6 +4663,7 @@
46584663
"request_for": "Request for",
46594664
"request_id": "Request ID",
46604665
"request_letter": "Request Letter",
4666+
"request_order": "Request Order",
46614667
"request_order_details": "Request Order Details",
46624668
"request_order_not_found": "Request order not found",
46634669
"request_orders": "Request Orders",

src/Utils/decimal.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export function roundWhole(value: string | number | Decimal): string {
7272
return new Decimal(value).toFixed(0);
7373
}
7474

75+
export function roundUp(value: string | number | Decimal): string {
76+
return new Decimal(value).toFixed(0, Decimal.ROUND_UP);
77+
}
78+
7579
/**
7680
* Compare two decimal values
7781
* Returns: -1 if a < b, 0 if a == b, 1 if a > b
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { useMemo, useState } from "react";
2+
import { useTranslation } from "react-i18next";
3+
4+
import { cn } from "@/lib/utils";
5+
6+
import Autocomplete from "@/components/ui/autocomplete";
7+
8+
import {
9+
fhirDosageToFrequencyValue,
10+
generateManSuggestions,
11+
MAN_FREQUENCY_PRESETS,
12+
manToFhirTiming,
13+
MEDICATION_REQUEST_TIMING_OPTIONS,
14+
MedicationRequestDosageInstruction,
15+
} from "@/types/emr/medicationRequest/medicationRequest";
16+
17+
interface DosageFrequencyInputProps {
18+
dosageInstruction: MedicationRequestDosageInstruction;
19+
onDosageInstructionChange: (
20+
updates: Partial<MedicationRequestDosageInstruction>,
21+
) => void;
22+
disabled?: boolean;
23+
hasError?: boolean;
24+
className?: string;
25+
}
26+
27+
/**
28+
* Smart single-field frequency autocomplete.
29+
*
30+
* Doctors type naturally -- "1-0-1", "1/2-0-1", "SOS", "Q6H" -- and the
31+
* system dynamically generates suggestions. Under the hood it maps to FHIR
32+
* Timing structures where possible, and falls back to dosageInstruction.text
33+
* for freeform patterns.
34+
*/
35+
export function DosageFrequencyInput({
36+
dosageInstruction,
37+
onDosageInstructionChange,
38+
disabled = false,
39+
hasError = false,
40+
className,
41+
}: DosageFrequencyInputProps) {
42+
const { t } = useTranslation();
43+
const [searchQuery, setSearchQuery] = useState("");
44+
45+
// Derive current value from existing dosage instruction (reverse mapping)
46+
// Only the fields read by fhirDosageToFrequencyValue are listed as deps
47+
// to avoid recomputing when unrelated dosageInstruction fields change.
48+
const currentValue = useMemo(
49+
() => fhirDosageToFrequencyValue(dosageInstruction),
50+
// eslint-disable-next-line react-hooks/exhaustive-deps
51+
[
52+
dosageInstruction?.text,
53+
dosageInstruction?.timing,
54+
dosageInstruction?.as_needed_boolean,
55+
],
56+
);
57+
58+
// Dynamically generate options based on what the user is typing
59+
const options = useMemo(() => {
60+
const query = searchQuery.trim();
61+
const results: { value: string; label: string }[] = [];
62+
const seen = new Set<string>();
63+
64+
const add = (value: string, label: string) => {
65+
if (seen.has(value)) return;
66+
seen.add(value);
67+
results.push({ value, label });
68+
};
69+
70+
// 1. Generate M-A-N pattern suggestions
71+
const manSuggestions = generateManSuggestions(query);
72+
for (const s of manSuggestions) {
73+
add(s.value, s.label);
74+
}
75+
76+
// 2. Add SOS option
77+
if (!query || "sos".startsWith(query.toLowerCase())) {
78+
add("SOS", "SOS (As needed)");
79+
}
80+
81+
// 3. Add STAT option
82+
if (!query || "stat".startsWith(query.toLowerCase())) {
83+
add("STAT", "STAT (Immediately)");
84+
}
85+
86+
// 4. Filter FHIR timing options by code or display text
87+
if (query) {
88+
const lowerQuery = query.toLowerCase();
89+
for (const [key, opt] of Object.entries(
90+
MEDICATION_REQUEST_TIMING_OPTIONS,
91+
)) {
92+
// Skip options that are already represented as M-A-N presets
93+
const isManPreset = MAN_FREQUENCY_PRESETS.some(
94+
(p) => p.timingKey === key,
95+
);
96+
if (isManPreset) continue;
97+
98+
if (
99+
key.toLowerCase().startsWith(lowerQuery) ||
100+
opt.display.toLowerCase().includes(lowerQuery) ||
101+
opt.timing.code?.display.toLowerCase().includes(lowerQuery)
102+
) {
103+
add(key, opt.display);
104+
}
105+
}
106+
} else {
107+
// When empty, show a few common FHIR codes after M-A-N presets
108+
for (const key of ["QD", "QOD", "Q6H", "Q8H", "Q12H", "BED", "WK"]) {
109+
const opt = MEDICATION_REQUEST_TIMING_OPTIONS[key];
110+
if (opt) add(key, opt.display);
111+
}
112+
}
113+
114+
// 5. Always include the currently selected value so the button displays correctly
115+
if (currentValue && !seen.has(currentValue)) {
116+
const preset = MAN_FREQUENCY_PRESETS.find((p) => p.man === currentValue);
117+
add(
118+
currentValue,
119+
preset ? `${currentValue} (${preset.label})` : currentValue,
120+
);
121+
}
122+
123+
// 6. If query looks like a valid M-A-N but isn't in our generated list,
124+
// add it as a "custom" entry so freeform is always allowed.
125+
const manFullRe = /^[\d]+(?:\/[\d]+)?(-[\d]+(?:\/[\d]+)?){1,3}$/;
126+
if (query && manFullRe.test(query) && !seen.has(query)) {
127+
add(query, query);
128+
}
129+
130+
return results;
131+
}, [searchQuery, currentValue]);
132+
133+
const handleChange = (value: string) => {
134+
if (!value) {
135+
// Cleared
136+
onDosageInstructionChange({
137+
timing: undefined,
138+
as_needed_boolean: false,
139+
as_needed_for: undefined,
140+
text: undefined,
141+
});
142+
return;
143+
}
144+
145+
// Try to map to FHIR timing
146+
const fhirMapping = manToFhirTiming(value);
147+
148+
if (fhirMapping) {
149+
if (fhirMapping.asNeeded) {
150+
// SOS / PRN
151+
onDosageInstructionChange({
152+
timing: undefined,
153+
as_needed_boolean: true,
154+
text: "SOS",
155+
});
156+
} else {
157+
// Standard FHIR timing (from M-A-N preset or direct FHIR code)
158+
const preset = MAN_FREQUENCY_PRESETS.find((p) => p.man === value);
159+
onDosageInstructionChange({
160+
timing: fhirMapping.timing,
161+
as_needed_boolean: false,
162+
as_needed_for: undefined,
163+
text: preset ? preset.man : undefined,
164+
});
165+
}
166+
} else {
167+
// Non-standard M-A-N or freeform -- store as text
168+
onDosageInstructionChange({
169+
text: value,
170+
as_needed_boolean: false,
171+
as_needed_for: undefined,
172+
timing: undefined,
173+
});
174+
}
175+
};
176+
177+
return (
178+
<Autocomplete
179+
options={options}
180+
value={currentValue}
181+
onChange={handleChange}
182+
onSearch={setSearchQuery}
183+
placeholder={t("frequency_placeholder")}
184+
inputPlaceholder={t("frequency_input_placeholder")}
185+
noOptionsMessage={t("no_frequency_found")}
186+
disabled={disabled}
187+
className={cn("h-9 text-sm", hasError && "border-red-500", className)}
188+
popoverContentClassName="w-80"
189+
showClearButton={false}
190+
/>
191+
);
192+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { useMemo, useState } from "react";
2+
import { useTranslation } from "react-i18next";
3+
4+
import { cn } from "@/lib/utils";
5+
6+
import Autocomplete from "@/components/ui/autocomplete";
7+
8+
import {
9+
BoundsDuration,
10+
decodeDurationValue,
11+
encodeDurationValue,
12+
formatDurationLabel,
13+
generateDurationSuggestions,
14+
parseDurationString,
15+
} from "@/types/emr/medicationRequest/medicationRequest";
16+
17+
interface DurationInputProps {
18+
value?: BoundsDuration;
19+
onChange: (duration: BoundsDuration | undefined) => void;
20+
disabled?: boolean;
21+
hasError?: boolean;
22+
className?: string;
23+
}
24+
25+
/**
26+
* Smart single-field duration autocomplete.
27+
*
28+
* Type a number and see contextual suggestions:
29+
* "5" → 5 days, 5 weeks, 5 months
30+
* "2w" → 2 weeks
31+
* "" → popular defaults: 3 days, 5 days, 7 days, 14 days, 1 month
32+
*/
33+
export function DurationInput({
34+
value,
35+
onChange,
36+
disabled = false,
37+
hasError = false,
38+
className,
39+
}: DurationInputProps) {
40+
const { t } = useTranslation();
41+
const [searchQuery, setSearchQuery] = useState("");
42+
43+
// Encode current value for the autocomplete
44+
const currentValue = useMemo(() => encodeDurationValue(value), [value]);
45+
46+
// Dynamic suggestions based on input
47+
const options = useMemo(() => {
48+
const suggestions = generateDurationSuggestions(searchQuery);
49+
50+
// If there's a current value and it's not in suggestions, add it at the top
51+
if (currentValue && !suggestions.find((s) => s.value === currentValue)) {
52+
const currentLabel = formatDurationLabel(value);
53+
if (currentLabel) {
54+
suggestions.unshift({ value: currentValue, label: currentLabel });
55+
}
56+
}
57+
58+
return suggestions;
59+
}, [searchQuery, currentValue, value]);
60+
61+
const handleChange = (selectedValue: string) => {
62+
if (!selectedValue) {
63+
onChange(undefined);
64+
return;
65+
}
66+
67+
// Try encoded format first (e.g. "5-d"), then raw text (e.g. "5 days")
68+
const decoded =
69+
decodeDurationValue(selectedValue) || parseDurationString(selectedValue);
70+
if (decoded) {
71+
onChange(decoded);
72+
}
73+
};
74+
75+
return (
76+
<Autocomplete
77+
options={options}
78+
value={currentValue}
79+
onChange={handleChange}
80+
onSearch={setSearchQuery}
81+
placeholder={t("duration_placeholder")}
82+
inputPlaceholder={t("duration_input_placeholder")}
83+
noOptionsMessage={t("no_duration_found")}
84+
disabled={disabled}
85+
className={cn("h-9 text-sm", hasError && "border-red-500", className)}
86+
popoverContentClassName="w-56"
87+
showClearButton={false}
88+
/>
89+
);
90+
}

src/components/Medicine/MedicationAdministration/GroupedMedicationRow.tsx

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ import {
2020
TooltipTrigger,
2121
} from "@/components/ui/tooltip";
2222

23-
import { getFrequencyDisplay } from "@/components/Medicine/MedicationsTable";
24-
import { formatDosage } from "@/components/Medicine/utils";
23+
import { formatDosage, formatFrequency } from "@/components/Medicine/utils";
2524

2625
import { MedicationAdministrationRead } from "@/types/emr/medicationAdministration/medicationAdministration";
2726
import {
@@ -107,11 +106,12 @@ const IndividualMedicationRow: React.FC<{
107106
isInactive && medication.status === "ended" && "line-through",
108107
)}
109108
>
110-
{formatDosage(medication.dosage_instruction[0])},{" "}
111-
{
112-
getFrequencyDisplay(medication.dosage_instruction[0]?.timing)
113-
?.meaning
114-
}
109+
{[
110+
formatDosage(medication.dosage_instruction[0]),
111+
formatFrequency(medication.dosage_instruction[0]),
112+
]
113+
.filter(Boolean)
114+
.join(", ")}
115115
</span>
116116
<Badge
117117
variant={medication.status === "active" ? "green" : "secondary"}
@@ -319,19 +319,19 @@ export const GroupedMedicationRow: React.FC<GroupedMedicationRowProps> = ({
319319
</div>
320320

321321
{/* Latest prescription dosage and frequency */}
322-
{latestActiveRequest && (
323-
<div className="text-sm text-gray-600 mt-0.5">
324-
{formatDosage(latestActiveRequest.dosage_instruction[0])}
325-
{getFrequencyDisplay(
326-
latestActiveRequest.dosage_instruction[0]?.timing,
327-
)?.meaning && <span className="text-gray-400"> · </span>}
328-
{
329-
getFrequencyDisplay(
330-
latestActiveRequest.dosage_instruction[0]?.timing,
331-
)?.meaning
332-
}
333-
</div>
334-
)}
322+
{latestActiveRequest &&
323+
(() => {
324+
const freq = formatFrequency(
325+
latestActiveRequest.dosage_instruction[0],
326+
);
327+
return (
328+
<div className="text-sm text-gray-600 mt-0.5">
329+
{formatDosage(latestActiveRequest.dosage_instruction[0])}
330+
{freq && <span className="text-gray-400"> · </span>}
331+
{freq}
332+
</div>
333+
);
334+
})()}
335335

336336
{/* Status and route badges */}
337337
<div className="flex flex-wrap gap-1 mt-1">

0 commit comments

Comments
 (0)