Add graphical CCS orders and finalization
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user