diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 0292fcd..7f020f3 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -112,7 +112,7 @@ public sealed class CcsCaseTests Assert.False(topics.Single(value => value.Id == "past_medical_history").Required); Assert.Contains("cholesterol was high", topics.Single(value => value.Id == "past_medical_history").PatientText, StringComparison.OrdinalIgnoreCase); - Assert.Equal(3, request.Cases.Single().GraphicalPresentation.InterviewRevision); + Assert.Equal(4, request.Cases.Single().GraphicalPresentation.InterviewRevision); Assert.Equal("Hyperlipidemia", CcsStore.MedicalHistory(request.Cases.Single())); var exams = request.Cases.Single().Exams; Assert.Equal([ @@ -124,6 +124,12 @@ public sealed class CcsCaseTests StringComparison.OrdinalIgnoreCase); Assert.Contains("brown stool", exams.Single(value => value.Code == "rectal").Finding, StringComparison.OrdinalIgnoreCase); + Assert.Contains(request.Orders, value => value.Code == "h_pylori_breath"); + Assert.Contains(request.Orders, value => value.Code == "bismuth_quadruple"); + Assert.Contains(request.Cases.Single().OrderResponses, + value => value.OrderCode == "upper_endoscopy" && value.Result.Contains("duodenal", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(request.Cases.Single().ScoringCriteria, + value => value.TargetCode == "ibuprofen" && value.Points < 0); } [Fact] diff --git a/content/ccs/duodenal-ulcer-office-001.json b/content/ccs/duodenal-ulcer-office-001.json index 3e556eb..2287ce0 100644 --- a/content/ccs/duodenal-ulcer-office-001.json +++ b/content/ccs/duodenal-ulcer-office-001.json @@ -1,5 +1,18 @@ { - "orders": [], + "orders": [ + { "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count", "hemoglobin"], "defaultTurnaroundMinutes": 20, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" }, + { "code": "cmp", "canonicalName": "Comprehensive metabolic panel", "category": "laboratory", "aliases": ["cmp", "chemistry", "bun", "creatinine"], "defaultTurnaroundMinutes": 25, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" }, + { "code": "iron_studies", "canonicalName": "Iron studies and ferritin", "category": "laboratory", "aliases": ["iron", "ferritin", "transferrin saturation"], "defaultTurnaroundMinutes": 30, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" }, + { "code": "h_pylori_breath", "canonicalName": "Helicobacter pylori urea breath test", "category": "diagnostic", "aliases": ["h pylori", "urea breath test", "helicobacter"], "defaultTurnaroundMinutes": 45, "availableLocations": ["office"], "displayRoute": "Breath", "displayFrequency": "Once", "resultSection": "other_test" }, + { "code": "upper_endoscopy", "canonicalName": "Upper endoscopy with biopsy", "category": "procedure", "aliases": ["egd", "endoscopy", "gastroscopy", "biopsy"], "defaultTurnaroundMinutes": 60, "availableLocations": ["office"], "displayRoute": "Endoscopic", "displayFrequency": "Once", "resultSection": "other_test" }, + { "code": "omeprazole", "canonicalName": "Omeprazole acid suppression", "category": "medication", "aliases": ["ppi", "proton pump inhibitor", "acid suppression"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "Twice daily", "resultSection": "treatment" }, + { "code": "bismuth_quadruple", "canonicalName": "Optimized bismuth quadruple therapy for 14 days", "category": "medication", "aliases": ["h pylori treatment", "bismuth", "tetracycline", "metronidazole"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "14-day course", "resultSection": "treatment" }, + { "code": "h_pylori_test_of_cure", "canonicalName": "H. pylori test of cure after therapy", "category": "diagnostic", "aliases": ["test of cure", "repeat breath test", "eradication testing"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Outpatient", "displayFrequency": "At least 4 weeks after therapy", "resultSection": "other_test" }, + { "code": "gastro_follow_up", "canonicalName": "Gastroenterology follow-up", "category": "disposition", "aliases": ["gi follow up", "gastroenterology", "follow-up"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Outpatient", "displayFrequency": "Follow-up", "resultSection": "treatment" }, + { "code": "abdominal_ct", "canonicalName": "CT abdomen and pelvis with contrast", "category": "imaging", "aliases": ["ct abdomen", "cat scan"], "defaultTurnaroundMinutes": 60, "availableLocations": [], "displayRoute": "Imaging", "displayFrequency": "Once", "resultSection": "imaging" }, + { "code": "clarithromycin_triple", "canonicalName": "Empiric clarithromycin triple therapy", "category": "medication", "aliases": ["clarithromycin", "triple therapy", "prevpac"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "14-day course", "resultSection": "treatment" }, + { "code": "ibuprofen", "canonicalName": "Ibuprofen for abdominal pain", "category": "medication", "aliases": ["nsaid", "motrin", "advil"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "As needed", "resultSection": "treatment" } + ], "cases": [ { "externalId": "ccs-gastro-duodenal-ulcer-office-001", @@ -14,7 +27,7 @@ "graphicalPresentation": { "sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male", - "interviewRevision": 3 + "interviewRevision": 4 }, "initialChart": { "patientSummary": "44-year-old man attending an office visit", @@ -124,7 +137,20 @@ { "code": "extremities_spine", "name": "Extremities / Spine", "durationMinutes": 1, "finding": "Extremities have full range of motion without edema, clubbing, cyanosis, or joint swelling. The spine is nontender." }, { "code": "neuro_psych", "name": "Neuro / Psych", "durationMinutes": 1, "finding": "He is alert and fully oriented with normal speech, strength, sensation, coordination, gait, mood, and affect." } ], - "orderResponses": [], + "orderResponses": [ + { "orderCode": "cbc", "turnaroundMinutes": 20, "result": "Hemoglobin is 10.4 g/dL, hematocrit 32%, MCV 74 fL, leukocyte count 7,100/mm3, and platelet count 312,000/mm3.", "resultSection": "lab" }, + { "orderCode": "cmp", "turnaroundMinutes": 25, "result": "BUN is 27 mg/dL and creatinine 1.0 mg/dL. Electrolytes, glucose, aminotransferases, bilirubin, and albumin are within reference ranges.", "resultSection": "lab" }, + { "orderCode": "iron_studies", "turnaroundMinutes": 30, "result": "Ferritin is 10 ng/mL, serum iron 31 mcg/dL, and transferrin saturation 9%, supporting iron deficiency.", "resultSection": "lab" }, + { "orderCode": "h_pylori_breath", "turnaroundMinutes": 45, "result": "Urea breath test is positive for Helicobacter pylori.", "resultSection": "other_test" }, + { "orderCode": "upper_endoscopy", "turnaroundMinutes": 60, "result": "Upper endoscopy shows an 8 mm clean-based ulcer in the duodenal bulb without active bleeding. Gastric biopsy is positive for H. pylori and shows no dysplasia or malignancy.", "resultSection": "other_test" }, + { "orderCode": "omeprazole", "turnaroundMinutes": 0, "result": "Proton-pump inhibitor therapy is started.", "resultSection": "treatment" }, + { "orderCode": "bismuth_quadruple", "turnaroundMinutes": 0, "result": "A 14-day optimized bismuth quadruple regimen is prescribed.", "resultSection": "treatment" }, + { "orderCode": "h_pylori_test_of_cure", "turnaroundMinutes": 0, "result": "Test-of-cure follow-up is scheduled for at least four weeks after therapy, with PPI held for two weeks before testing.", "resultSection": "treatment" }, + { "orderCode": "gastro_follow_up", "turnaroundMinutes": 0, "result": "Gastroenterology follow-up is arranged to review healing, anemia, and eradication testing.", "resultSection": "treatment" }, + { "orderCode": "abdominal_ct", "turnaroundMinutes": 60, "result": "CT of the abdomen and pelvis shows no acute intra-abdominal abnormality.", "resultSection": "imaging" }, + { "orderCode": "clarithromycin_triple", "turnaroundMinutes": 0, "result": "Empiric clarithromycin-based therapy is prescribed without susceptibility confirmation.", "resultSection": "treatment" }, + { "orderCode": "ibuprofen", "turnaroundMinutes": 0, "result": "Ibuprofen is prescribed despite suspected peptic ulcer disease and possible gastrointestinal bleeding.", "resultSection": "treatment" } + ], "events": [], "scoringCriteria": [ { "id": "elicit_chief_concern", "title": "Elicit the chief concern", "actionType": "dialogue_choice", "targetCode": "chief_concern", "points": 10, "rationale": "An open-ended opening allows the patient to identify lightheadedness and fatigue in his own words." }, @@ -140,7 +166,16 @@ { "id": "examine_heart", "title": "Examine the cardiovascular system", "actionType": "exam", "targetCode": "heart_cardiovascular", "points": 5, "rationale": "Cardiovascular examination helps assess presyncope and hemodynamic status." }, { "id": "examine_abdomen", "title": "Examine the abdomen", "actionType": "exam", "targetCode": "abdomen", "points": 15, "rationale": "Focused abdominal examination identifies epigastric tenderness and screens for peritoneal signs or masses." }, { "id": "perform_rectal_exam", "title": "Perform a rectal examination", "actionType": "exam", "targetCode": "rectal", "points": 10, "rationale": "Rectal examination can assess for current gross gastrointestinal bleeding when the history includes black stool." }, - { "id": "perform_neurologic_exam", "title": "Perform a neurologic examination", "actionType": "exam", "targetCode": "neuro_psych", "points": 5, "rationale": "A focused neurologic examination helps evaluate lightheadedness and exclude focal deficits." } + { "id": "perform_neurologic_exam", "title": "Perform a neurologic examination", "actionType": "exam", "targetCode": "neuro_psych", "points": 5, "rationale": "A focused neurologic examination helps evaluate lightheadedness and exclude focal deficits." }, + { "id": "check_cbc", "title": "Assess anemia with a CBC", "actionType": "order", "targetCode": "cbc", "points": 12, "rationale": "Fatigue, lightheadedness, and intermittent melena warrant assessment for blood-loss anemia." }, + { "id": "check_iron", "title": "Confirm iron deficiency", "actionType": "order", "targetCode": "iron_studies", "points": 6, "rationale": "Iron studies characterize the microcytic anemia." }, + { "id": "test_h_pylori", "title": "Test for H. pylori", "actionType": "order", "targetCode": "h_pylori_breath", "points": 12, "rationale": "H. pylori testing identifies a treatable cause of peptic ulcer disease." }, + { "id": "perform_egd", "title": "Evaluate suspected bleeding with upper endoscopy", "actionType": "order", "targetCode": "upper_endoscopy", "points": 12, "rationale": "Endoscopy confirms the ulcer and evaluates bleeding risk." }, + { "id": "start_ppi", "title": "Begin proton-pump inhibitor therapy", "actionType": "order", "targetCode": "omeprazole", "points": 8, "rationale": "Acid suppression promotes ulcer healing." }, + { "id": "eradicate_h_pylori", "title": "Treat H. pylori appropriately", "actionType": "order", "targetCode": "bismuth_quadruple", "points": 15, "afterTargetCode": "h_pylori_breath", "rationale": "Optimized bismuth quadruple therapy is an appropriate empiric eradication regimen." }, + { "id": "confirm_eradication", "title": "Arrange H. pylori test of cure", "actionType": "order", "targetCode": "h_pylori_test_of_cure", "points": 8, "afterTargetCode": "bismuth_quadruple", "rationale": "Eradication should be confirmed after treatment." }, + { "id": "avoid_clarithromycin", "title": "Avoid unsupported clarithromycin triple therapy", "actionType": "order", "targetCode": "clarithromycin_triple", "points": -8, "rationale": "Empiric clarithromycin therapy should be avoided without demonstrated susceptibility." }, + { "id": "avoid_nsaid", "title": "Avoid NSAIDs", "actionType": "order", "targetCode": "ibuprofen", "points": -12, "rationale": "NSAIDs may worsen ulcer injury and gastrointestinal bleeding." } ], "references": [ { diff --git a/scenes/ccs_cases_2/doctor_office.tscn b/scenes/ccs_cases_2/doctor_office.tscn index d663f33..d447e79 100644 --- a/scenes/ccs_cases_2/doctor_office.tscn +++ b/scenes/ccs_cases_2/doctor_office.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=13 format=3] +[gd_scene load_steps=14 format=3] [ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/office-backdrop.png" id="1_backdrop"] [ext_resource type="Script" path="res://scripts/ui/ccs_cases_2/doctor_office.gd" id="2_script"] @@ -11,6 +11,7 @@ [ext_resource type="Texture2D" path="res://assets/icons/lucide/x.svg" id="9_close"] [ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/vitals.svg" id="10_vitals"] [ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/medical-history.svg" id="11_history"] +[ext_resource type="Texture2D" path="res://assets/icons/lucide/clipboard-list.svg" id="12_orders"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"] shader = ExtResource("5_patient_fit") @@ -333,6 +334,37 @@ expand_icon = true icon_alignment = 1 vertical_icon_alignment = 0 +[node name="OrdersLauncher" type="Button" parent="ComputerOverlay"] +layout_mode = 0 +offset_left = 30.0 +offset_top = 276.0 +offset_right = 142.0 +offset_bottom = 382.0 +mouse_default_cursor_shape = 2 +icon = ExtResource("12_orders") +icon_max_width = 58 +expand_icon = true +icon_alignment = 1 +vertical_icon_alignment = 0 + +[node name="Label" type="Label" parent="ComputerOverlay/OrdersLauncher"] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -34.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.96, 0.98, 1, 1) +theme_override_colors/font_outline_color = Color(0.01, 0.04, 0.07, 0.95) +theme_override_constants/outline_size = 4 +theme_override_font_sizes/font_size = 17 +text = "Orders" +horizontal_alignment = 1 +vertical_alignment = 1 + [node name="Label" type="Label" parent="ComputerOverlay/MedicalHistoryLauncher"] layout_mode = 1 anchors_preset = 12 diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index cde96b6..c206e60 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -3,6 +3,11 @@ extends Control signal choice_selected(choice_id: String) signal physical_exam_selected(exam_code: String) signal physical_exam_finish_requested +signal order_search_requested(query: String) +signal orders_submit_requested(codes: Array) +signal order_cancel_requested(code: String) +signal time_advance_requested(minutes: int) +signal final_orders_submit_requested signal debrief_return_requested signal debrief_repeat_requested @@ -45,6 +50,7 @@ const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.0 @onready var medical_history_header: HBoxContainer = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header @onready var medical_history_close_button: Button = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header/Close @onready var medical_history_text: Label = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Text +@onready var orders_launcher: Button = $ComputerOverlay/OrdersLauncher var blink_tween: Tween var dialogue_tween: Tween @@ -67,6 +73,23 @@ var medical_history_exam_text: RichTextLabel var medical_history_exam_scroll: ScrollContainer var medical_history_tab_button: Button var physical_exam_tab_button: Button +var orders_window: PanelContainer +var orders_search: LineEdit +var orders_search_results: VBoxContainer +var orders_staged: VBoxContainer +var orders_active: VBoxContainer +var orders_results: RichTextLabel +var orders_tabs: TabContainer +var orders_confirm_button: Button +var orders_advance_button: Button +var orders_advance_option: OptionButton +var orders_submit_final_button: Button +var orders_status: Label +var staged_order_codes: Array[String] = [] +var order_names := {} +var final_orders_mode := false +var active_order_codes: Array = [] +var known_order_result_count := -1 var experience_mode := "management" var pending_choices: Array = [] var available_choices: Array = [] @@ -93,6 +116,7 @@ func _ready() -> void: vitals_close_button.pressed.connect(close_vitals) medical_history_launcher.pressed.connect(open_medical_history) medical_history_close_button.pressed.connect(close_medical_history) + orders_launcher.pressed.connect(open_orders) vitals_header.gui_input.connect(_on_computer_window_header_input.bind(vitals_window, vitals_close_button)) medical_history_header.gui_input.connect(_on_computer_window_header_input.bind( medical_history_window, medical_history_close_button)) @@ -103,7 +127,9 @@ func _ready() -> void: _style_computer_window(medical_history_window) _style_desktop_shortcut(vitals_launcher) _style_desktop_shortcut(medical_history_launcher) + _style_desktop_shortcut(orders_launcher) _build_medical_history_tabs() + _build_orders_window() _resize_computer_windows() _update_clock() _schedule_patient_blink() @@ -325,8 +351,11 @@ func open_computer() -> void: func close_computer() -> bool: if not computer_overlay.visible: return false + if final_orders_mode: + return false close_vitals() close_medical_history() + close_orders() computer_overlay.visible = false computer_launcher.visible = computer_available if dialogue_tween and dialogue_tween.is_valid(): @@ -352,6 +381,8 @@ func close_transient_overlay() -> bool: return close_vitals() if top_window == medical_history_window: return close_medical_history() + if top_window == orders_window: + return close_orders() return close_computer() @@ -397,6 +428,278 @@ func is_medical_history_open() -> bool: return medical_history_window.visible +func _build_orders_window() -> void: + orders_window = PanelContainer.new() + orders_window.name = "OrdersWindow" + orders_window.visible = false + orders_window.z_index = 2 + orders_window.size = Vector2(1040.0, 700.0) + orders_window.position = Vector2(280.0, 100.0) + _style_computer_window(orders_window) + computer_overlay.add_child(orders_window) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 28) + margin.add_theme_constant_override("margin_top", 22) + margin.add_theme_constant_override("margin_right", 28) + margin.add_theme_constant_override("margin_bottom", 24) + orders_window.add_child(margin) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 12) + margin.add_child(content) + var header := HBoxContainer.new() + header.mouse_default_cursor_shape = Control.CURSOR_MOVE + content.add_child(header) + var title := Label.new() + title.text = "Orders" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.add_theme_font_size_override("font_size", 30) + header.add_child(title) + var close := Button.new() + close.text = "Close" + close.pressed.connect(close_orders) + header.add_child(close) + header.gui_input.connect(_on_computer_window_header_input.bind(orders_window, close)) + orders_window.gui_input.connect(_on_computer_window_input.bind(orders_window)) + orders_status = Label.new() + orders_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(orders_status) + orders_tabs = TabContainer.new() + orders_tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL + content.add_child(orders_tabs) + _build_order_search_tab() + _build_active_orders_tab() + _build_order_results_tab() + var actions := HBoxContainer.new() + actions.alignment = BoxContainer.ALIGNMENT_END + content.add_child(actions) + orders_advance_option = OptionButton.new() + for item in [["10 minutes", 10], ["1 hour", 60], ["4 hours", 240], ["1 day", 1440]]: + orders_advance_option.add_item(item[0]) + orders_advance_option.set_item_metadata(orders_advance_option.item_count - 1, item[1]) + actions.add_child(orders_advance_option) + orders_advance_button = Button.new() + orders_advance_button.text = "Obtain Results" + orders_advance_button.pressed.connect(func(): time_advance_requested.emit( + int(orders_advance_option.get_item_metadata(orders_advance_option.selected)))) + actions.add_child(orders_advance_button) + orders_submit_final_button = Button.new() + orders_submit_final_button.text = "Submit Final Orders" + orders_submit_final_button.visible = false + orders_submit_final_button.pressed.connect(func(): final_orders_submit_requested.emit()) + actions.add_child(orders_submit_final_button) + + +func _build_order_search_tab() -> void: + var page := VBoxContainer.new() + page.name = "Search" + page.add_theme_constant_override("separation", 8) + orders_tabs.add_child(page) + var row := HBoxContainer.new() + page.add_child(row) + orders_search = LineEdit.new() + orders_search.placeholder_text = "Search tests, medications, treatments, and procedures" + orders_search.size_flags_horizontal = Control.SIZE_EXPAND_FILL + orders_search.text_submitted.connect(func(_value): _request_order_search()) + row.add_child(orders_search) + var search_button := Button.new() + search_button.text = "Search" + search_button.pressed.connect(_request_order_search) + row.add_child(search_button) + var search_scroll := ScrollContainer.new() + search_scroll.custom_minimum_size.y = 170.0 + search_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + page.add_child(search_scroll) + orders_search_results = VBoxContainer.new() + orders_search_results.size_flags_horizontal = Control.SIZE_EXPAND_FILL + search_scroll.add_child(orders_search_results) + page.add_child(Label.new()) + page.get_child(page.get_child_count() - 1).text = "Orders awaiting confirmation" + orders_staged = VBoxContainer.new() + page.add_child(orders_staged) + orders_confirm_button = Button.new() + orders_confirm_button.text = "Confirm Orders" + orders_confirm_button.disabled = true + orders_confirm_button.pressed.connect(_confirm_staged_orders) + page.add_child(orders_confirm_button) + + +func _build_active_orders_tab() -> void: + var page := VBoxContainer.new() + page.name = "Active Orders" + orders_tabs.add_child(page) + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + page.add_child(scroll) + orders_active = VBoxContainer.new() + orders_active.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(orders_active) + + +func _build_order_results_tab() -> void: + var page := VBoxContainer.new() + page.name = "Results" + orders_tabs.add_child(page) + orders_results = RichTextLabel.new() + orders_results.bbcode_enabled = true + orders_results.size_flags_vertical = Control.SIZE_EXPAND_FILL + page.add_child(orders_results) + + +func open_orders() -> void: + if not computer_overlay.visible: + return + orders_window.visible = true + orders_launcher.get_node("Label").text = "Orders" + _bring_computer_window_to_front(orders_window) + orders_search.grab_focus() + + +func close_orders() -> bool: + if orders_window == null or not orders_window.visible: + return false + if final_orders_mode: + return false + orders_window.visible = false + return true + + +func _request_order_search() -> void: + var query := orders_search.text.strip_edges() + if query.length() < 2: + orders_status.text = "Enter at least two characters to search." + return + orders_status.text = "Searching..." + order_search_requested.emit(query) + + +func render_order_search(rows: Array) -> void: + _clear_dynamic_children(orders_search_results) + orders_status.text = "" if not rows.is_empty() else "No matching orders found." + for raw_order in rows: + var order: Dictionary = raw_order + var code := str(order.get("code", "")) + order_names[code] = str(order.get("canonicalName", code.capitalize())) + var button := Button.new() + button.text = "%s · %s" % [order_names[code], str(order.get("category", "")).capitalize()] + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.disabled = code in staged_order_codes or code in active_order_codes + button.pressed.connect(_stage_order.bind(code)) + orders_search_results.add_child(button) + + +func _stage_order(code: String) -> void: + if code.is_empty() or code in staged_order_codes: + return + staged_order_codes.append(code) + _render_staged_orders() + + +func _render_staged_orders() -> void: + _clear_dynamic_children(orders_staged) + for code in staged_order_codes: + var row := HBoxContainer.new() + orders_staged.add_child(row) + var label := Label.new() + label.text = str(order_names.get(code, code.capitalize())) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(label) + var remove := Button.new() + remove.text = "Remove" + remove.pressed.connect(_remove_staged_order.bind(code)) + row.add_child(remove) + orders_confirm_button.disabled = staged_order_codes.is_empty() + + +func _remove_staged_order(code: String) -> void: + staged_order_codes.erase(code) + _render_staged_orders() + + +func _confirm_staged_orders() -> void: + if staged_order_codes.is_empty(): + return + orders_submit_requested.emit(staged_order_codes.duplicate()) + + +func configure_orders(attempt: Dictionary) -> void: + if orders_window == null: + return + active_order_codes = attempt.get("activeOrderCodes", []).duplicate() + for code in attempt.get("activeOrderCodes", []): + staged_order_codes.erase(str(code)) + _render_staged_orders() + _render_active_orders(attempt.get("activeOrders", [])) + _render_order_results(attempt.get("chart", [])) + var result_count := _order_result_count(attempt.get("chart", [])) + if known_order_result_count >= 0 and result_count > known_order_result_count and not orders_window.visible: + orders_launcher.get_node("Label").text = "Orders •" + known_order_result_count = result_count + final_orders_mode = str(attempt.get("state", "")) == "final_orders" + orders_advance_button.visible = not final_orders_mode + orders_advance_option.visible = not final_orders_mode + orders_submit_final_button.visible = final_orders_mode + if final_orders_mode: + var deadline := str(attempt.get("finalOrdersDeadlineUtc", "")) + orders_status.text = "FINAL ORDERS — Add or cancel orders, then submit to complete the case.%s" % [ + " Deadline: %s" % deadline if not deadline.is_empty() else ""] + open_computer() + open_orders() + + +func _render_active_orders(rows: Array) -> void: + _clear_dynamic_children(orders_active) + if rows.is_empty(): + var empty := Label.new() + empty.text = "No active orders." + orders_active.add_child(empty) + return + for raw_order in rows: + var order: Dictionary = raw_order + var row := HBoxContainer.new() + orders_active.add_child(row) + var label := Label.new() + label.text = "%s — %s, %s" % [str(order.get("name", "Order")), str(order.get("route", "")), str(order.get("frequency", ""))] + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(label) + var cancel := Button.new() + cancel.text = "Cancel" + cancel.pressed.connect(_emit_cancel_order.bind(str(order.get("code", "")))) + row.add_child(cancel) + + +func _render_order_results(chart: Array) -> void: + var parts: Array[String] = [] + for raw_entry in chart: + var entry: Dictionary = raw_entry + if str(entry.get("category", "")) in ["lab", "imaging", "other_test", "treatment"]: + parts.append("[b]%s[/b]\n%s" % [str(entry.get("title", "Result")), str(entry.get("text", ""))]) + orders_results.text = "\n\n".join(parts) if not parts.is_empty() else "No results are available." + + +func _order_result_count(chart: Array) -> int: + var count := 0 + for raw_entry in chart: + if str(raw_entry.get("category", "")) in ["lab", "imaging", "other_test", "treatment"]: + count += 1 + return count + + +func set_orders_pending(pending: bool, message: String = "") -> void: + orders_confirm_button.disabled = pending or staged_order_codes.is_empty() + orders_advance_button.disabled = pending + orders_submit_final_button.disabled = pending + orders_status.text = message + + +func _emit_cancel_order(code: String) -> void: + order_cancel_requested.emit(code) + + +func _clear_dynamic_children(parent: Node) -> void: + for child in parent.get_children(): + child.queue_free() + + func _build_medical_history_tabs() -> void: var content := medical_history_text.get_parent() as VBoxContainer medical_history_section_title = content.get_node("SectionTitle") as Label @@ -639,7 +942,7 @@ func _bring_computer_window_to_front(window: Control) -> void: func _topmost_computer_window() -> Control: var top_window: Control - for window in [vitals_window, medical_history_window]: + for window in [vitals_window, medical_history_window, orders_window]: if window.visible and (top_window == null or window.z_index > top_window.z_index): top_window = window return top_window diff --git a/scripts/ui/ccs_cases_2_screen.gd b/scripts/ui/ccs_cases_2_screen.gd index 549d628..438a757 100644 --- a/scripts/ui/ccs_cases_2_screen.gd +++ b/scripts/ui/ccs_cases_2_screen.gd @@ -5,7 +5,7 @@ const Style := preload("res://scripts/ui/ui_style.gd") const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn") const DESIGN_SIZE := Vector2(1600.0, 900.0) const FOCUSED_INTERVIEW_CASE_ID := "ccs-gastro-duodenal-ulcer-office-001" -const FOCUSED_INTERVIEW_REVISION := 3 +const FOCUSED_INTERVIEW_REVISION := 4 var main var stage_root: Control @@ -43,6 +43,8 @@ func _connect_service() -> void: CCSService.attempt_changed.connect(_on_attempt_changed) if not CCSService.request_failed.is_connected(_on_request_failed): CCSService.request_failed.connect(_on_request_failed) + if not CCSService.orders_changed.is_connected(_on_orders_changed): + CCSService.orders_changed.connect(_on_orders_changed) if not CCSService.attempt_abandoned.is_connected(_on_attempt_abandoned): CCSService.attempt_abandoned.connect(_on_attempt_abandoned) @@ -97,10 +99,19 @@ func _on_attempt_changed() -> void: and current_view == "doctor_office": _refresh_exam_result(CCSService.attempt, completed_exam_code) return + if completed_action in ["orders", "cancel_order", "advance", "enter_final_orders"] \ + and active_scene != null and current_view == "doctor_office": + _refresh_case_workspace(CCSService.attempt) + return _render_state() func _refresh_exam_result(attempt: Dictionary, exam_code: String) -> void: + _refresh_case_workspace(attempt) + active_scene.show_exam_result(attempt.get("chart", []), exam_code) + + +func _refresh_case_workspace(attempt: Dictionary) -> void: active_scene.configure_presentation( attempt.get("graphicalPresentation", {}), str(attempt.get("simulatedStartUtc", "")), @@ -108,7 +119,7 @@ func _refresh_exam_result(attempt: Dictionary, exam_code: String) -> void: attempt.get("vitals", {}), str(attempt.get("medicalHistory", "")), attempt.get("chart", [])) active_scene.configure_examinations(attempt.get("availableExams", []), attempt.get("chart", [])) - active_scene.show_exam_result(attempt.get("chart", []), exam_code) + active_scene.configure_orders(attempt) func _render_state() -> void: @@ -128,7 +139,7 @@ func _render_state() -> void: func _render_attempt(attempt: Dictionary) -> void: - if str(attempt.get("state", "")) != "active": + if str(attempt.get("state", "")) not in ["active", "final_orders"]: _show_unsupported_attempt("This active case is not available in the graphical interview yet.") return var interview: Dictionary = attempt.get("interview", {}) @@ -243,6 +254,11 @@ func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void: active_scene.choice_selected.connect(_submit_dialogue_choice) active_scene.physical_exam_selected.connect(_submit_physical_exam) active_scene.physical_exam_finish_requested.connect(_finish_physical_examination) + active_scene.order_search_requested.connect(_search_orders) + active_scene.orders_submit_requested.connect(_submit_orders) + active_scene.order_cancel_requested.connect(_cancel_order) + active_scene.time_advance_requested.connect(_advance_time) + active_scene.final_orders_submit_requested.connect(_submit_final_orders) active_scene.debrief_return_requested.connect(_return_to_cases) active_scene.debrief_repeat_requested.connect(_repeat_interview) selection_view.visible = false @@ -256,6 +272,7 @@ func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void: str(attempt.get("medicalHistory", "")), attempt.get("chart", [])) active_scene.configure_examinations(attempt.get("availableExams", []), attempt.get("chart", [])) active_scene.configure_interview(node) + active_scene.configure_orders(attempt) active_scene.set_choices_pending(not pending_action.is_empty()) @@ -279,8 +296,52 @@ func _submit_physical_exam(exam_code: String) -> void: func _finish_physical_examination() -> void: if not pending_action.is_empty(): return - pending_action = "finishing_exam" + pending_action = "enter_final_orders" active_scene.set_physical_exam_pending("finishing_case") + CCSService.send_command("end_case") + + +func _search_orders(query: String) -> void: + CCSService.search_orders(query) + + +func _on_orders_changed() -> void: + if active_scene != null and active_scene.has_method("render_order_search"): + active_scene.render_order_search(CCSService.orders) + + +func _submit_orders(codes: Array) -> void: + if not pending_action.is_empty() or codes.is_empty(): + return + pending_action = "orders" + active_scene.set_orders_pending(true, "Submitting orders...") + var commands: Array = [] + for code in codes: + commands.append({"type": "order", "code": str(code), "minutes": 0}) + CCSService.send_batch(commands) + + +func _cancel_order(code: String) -> void: + if not pending_action.is_empty() or code.is_empty(): + return + pending_action = "cancel_order" + active_scene.set_orders_pending(true, "Cancelling order...") + CCSService.send_batch([{"type": "cancel_order", "code": code, "minutes": 0}]) + + +func _advance_time(minutes: int) -> void: + if not pending_action.is_empty() or minutes <= 0: + return + pending_action = "advance" + active_scene.set_orders_pending(true, "Obtaining results...") + CCSService.send_command("advance", "", minutes) + + +func _submit_final_orders() -> void: + if not pending_action.is_empty(): + return + pending_action = "submit_final_orders" + active_scene.set_orders_pending(true, "Submitting final orders...") CCSService.send_command("exit_case") @@ -361,6 +422,8 @@ func _on_request_failed(message: String) -> void: active_scene.show_physical_exam_error(message) else: active_scene.set_choices_pending(false) + if active_scene.has_method("set_orders_pending"): + active_scene.set_orders_pending(false, message) else: status_label.text = message retry_button.visible = failed_action == "loading" diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index 4f1e6ab..e0e2677 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -71,7 +71,7 @@ func _run() -> void: _assert(screen._is_retired_interview_revision(retired_attempt, { "id": "fatigue_course", "patientText": "I feel tired.", "choices": [] }), "an older authored interview revision should trigger a restart") - retired_attempt.graphicalPresentation.interviewRevision = 3 + retired_attempt.graphicalPresentation.interviewRevision = 4 _assert(not screen._is_retired_interview_revision(retired_attempt, {}), "the current authored interview revision should remain active") @@ -91,11 +91,33 @@ func _run() -> void: _assert(screen.active_scene.computer_overlay.get_node("Screen").stretch_mode == TextureRect.STRETCH_SCALE, "the computer should fill the viewport without side bars") _assert(screen.active_scene.vitals_launcher.visible, "the computer desktop should expose a Vitals app") _assert(screen.active_scene.medical_history_launcher.visible, "the computer desktop should expose a Medical History app") + _assert(screen.active_scene.orders_launcher.visible, "the computer desktop should expose an Orders app") _assert(screen.active_scene.vitals_launcher.text.is_empty() and screen.active_scene.medical_history_launcher.text.is_empty(), "desktop shortcuts should place labels below centered icons") _assert(screen.active_scene.vitals_launcher.get_node("Label").text == "Vitals" and screen.active_scene.medical_history_launcher.get_node("Label").text == "Medical History", "desktop shortcut labels should remain readable") _assert(screen.active_scene.vitals_launcher.get_theme_stylebox("normal") is StyleBoxEmpty and screen.active_scene.medical_history_launcher.get_theme_stylebox("normal") is StyleBoxEmpty, "desktop shortcuts should not render permanent button tiles") _assert(screen.active_scene.medical_history_launcher.position.y > screen.active_scene.vitals_launcher.get_rect().end.y, "desktop shortcuts should form a vertical icon column") _assert(screen.active_scene.vitals_launcher.icon.get_width() >= 58 and screen.active_scene.medical_history_launcher.icon.get_width() >= 58, "desktop shortcuts should use high-resolution vector imports") + screen.active_scene.orders_launcher.pressed.emit() + _assert(screen.active_scene.orders_window.visible and screen.active_scene.orders_tabs.get_tab_count() == 3, "Orders should open a three-tab clinical workspace") + screen.active_scene.render_order_search([{"code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory"}]) + (screen.active_scene.orders_search_results.get_child(0) as Button).pressed.emit() + _assert(screen.active_scene.staged_order_codes == ["cbc"] and not screen.active_scene.orders_confirm_button.disabled, "catalog results should stage for confirmation") + screen.active_scene.configure_orders({ + "state": "active", "activeOrderCodes": ["cbc"], + "activeOrders": [{"code": "cbc", "name": "Complete blood count", "route": "Blood", "frequency": "Once"}], + "chart": [{"category": "lab", "code": "cbc", "title": "Complete blood count", "text": "Hemoglobin 10.4 g/dL."}] + }) + _assert(screen.active_scene.staged_order_codes.is_empty(), "confirmed active orders should leave the staged list") + _assert(screen.active_scene.orders_results.text.contains("Hemoglobin 10.4"), "returned results should render in the Orders results tab") + screen.active_scene.close_orders() + screen.active_scene.configure_orders({"state": "final_orders", "activeOrderCodes": [], "activeOrders": [], "chart": [], "finalOrdersDeadlineUtc": "2026-08-14T12:00:00Z"}) + _assert(screen.active_scene.is_computer_open() and screen.active_scene.orders_window.visible, "final orders should automatically open the Orders app") + _assert(screen.active_scene.orders_submit_final_button.visible and not screen.active_scene.orders_advance_button.visible, "final orders should expose submission and lock time advancement") + _assert(not screen.active_scene.close_orders(), "the Orders app should remain locked open during final orders") + screen.active_scene.final_orders_mode = false + screen.active_scene.close_orders() + screen.active_scene.close_computer() + screen.active_scene.open_computer() screen.active_scene.vitals_launcher.pressed.emit() _assert(screen.active_scene.is_vitals_open(), "the Vitals app should open a nurse vital-sign window") _assert(screen.active_scene.vitals_header.get_node("Title").text == "Vitals", "the vital window should use the concise Vitals heading") @@ -308,7 +330,7 @@ func _sample_attempt(completed: bool) -> Dictionary: return { "id": "attempt-1", "state": "active", "revision": 1, "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0, - "graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male", "interviewRevision": 3}, + "graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male", "interviewRevision": 4}, "medicalHistory": "Hyperlipidemia", "vitals": { "temperature": "36.8 C (98.2 F)", "bloodPressure": "122/76 mm Hg",