diff --git a/app/Http/Controllers/Care/MarController.php b/app/Http/Controllers/Care/MarController.php new file mode 100644 index 0000000..0542a0b --- /dev/null +++ b/app/Http/Controllers/Care/MarController.php @@ -0,0 +1,112 @@ +authorizeAbility($request, 'mar.view'); + $this->authorizeOwner($request, $careUnit); + $organization = $this->organization($request); + abort_unless((int) $careUnit->organization_id === (int) $organization->id, 404); + + $day = $request->filled('date') + ? Carbon::parse($request->input('date'))->startOfDay() + : now()->startOfDay(); + + $rows = $mar->unitRoundBoard($careUnit, $this->ownerRef($request), $day); + $canAdminister = app(CarePermissions::class)->can($this->member($request), 'mar.administer'); + + return view('care.nursing.mar-board', [ + 'unit' => $careUnit->load('department.branch'), + 'day' => $day, + 'rows' => $rows, + 'canAdminister' => $canAdminister, + 'statuses' => config('care.mar_administration_statuses'), + 'stateLabels' => config('care.mar_slot_states'), + ]); + } + + public function visitChart(Request $request, Visit $visit, MarService $mar): View + { + $this->authorizeAbility($request, 'mar.view'); + $this->authorizeOwner($request, $visit); + $this->authorizeBranch($request, (int) $visit->branch_id); + + $day = $request->filled('date') + ? Carbon::parse($request->input('date'))->startOfDay() + : now()->startOfDay(); + + $orders = $mar->syncOrdersForVisit($visit, $this->ownerRef($request)); + $chart = []; + foreach ($orders as $order) { + $chart[] = [ + 'order' => $order, + 'slots' => $mar->dueSlotsForOrder($order, $day), + ]; + } + + return view('care.nursing.mar-visit', [ + 'visit' => $visit->load(['patient', 'careUnit', 'bed']), + 'day' => $day, + 'chart' => $chart, + 'canAdminister' => app(CarePermissions::class)->can($this->member($request), 'mar.administer'), + 'statuses' => config('care.mar_administration_statuses'), + 'stateLabels' => config('care.mar_slot_states'), + 'frequencies' => config('care.mar_frequency_codes'), + ]); + } + + public function administer(Request $request, MarOrder $marOrder, MarService $mar): RedirectResponse + { + $this->authorizeAbility($request, 'mar.administer'); + $this->authorizeOwner($request, $marOrder); + + $validated = $request->validate([ + 'status' => ['required', 'string', Rule::in(array_keys(config('care.mar_administration_statuses')))], + 'scheduled_for' => ['nullable', 'date'], + 'dose_given' => ['nullable', 'string', 'max:255'], + 'route' => ['nullable', 'string', 'max:100'], + 'witness_by' => ['nullable', 'string', 'max:255'], + 'reason' => ['nullable', 'string', 'max:500'], + 'notes' => ['nullable', 'string', 'max:2000'], + 'redirect_to' => ['nullable', 'string', 'max:500'], + ]); + + $mar->recordAdministration( + $marOrder, + $validated['status'], + $this->ownerRef($request), + $this->actorRef($request), + isset($validated['scheduled_for']) ? Carbon::parse($validated['scheduled_for']) : null, + $validated['dose_given'] ?? null, + $validated['route'] ?? null, + $validated['witness_by'] ?? null, + $validated['reason'] ?? null, + $validated['notes'] ?? null, + ); + + $redirect = $validated['redirect_to'] ?? null; + if ($redirect && str_starts_with($redirect, url('/'))) { + return redirect()->to($redirect)->with('success', 'MAR entry recorded.'); + } + + return back()->with('success', 'MAR entry recorded.'); + } +} diff --git a/app/Http/Controllers/Care/NursingDocumentationController.php b/app/Http/Controllers/Care/NursingDocumentationController.php new file mode 100644 index 0000000..b05f545 --- /dev/null +++ b/app/Http/Controllers/Care/NursingDocumentationController.php @@ -0,0 +1,189 @@ +authorizeAbility($request, 'nursing.notes.view'); + $this->authorizeOwner($request, $careUnit); + abort_unless((int) $careUnit->organization_id === (int) $this->organization($request)->id, 404); + + $placedVisits = Visit::owned($this->ownerRef($request)) + ->where('care_unit_id', $careUnit->id) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->with(['patient', 'bed']) + ->orderBy('placed_at') + ->get(); + + return view('care.nursing.notes', [ + 'unit' => $careUnit->load('department.branch'), + 'notes' => $docs->notesForUnit($careUnit, $this->ownerRef($request)), + 'placedVisits' => $placedVisits, + 'canWrite' => app(CarePermissions::class)->can($this->member($request), 'nursing.notes.manage'), + 'noteTypes' => config('care.nursing_note_types'), + 'shiftCodes' => config('care.staff_shift_codes'), + ]); + } + + public function storeNote(Request $request, CareUnit $careUnit, NursingDocumentationService $docs): RedirectResponse + { + $this->authorizeAbility($request, 'nursing.notes.manage'); + $this->authorizeOwner($request, $careUnit); + + $validated = $request->validate([ + 'visit_id' => ['required', 'integer', 'exists:care_visits,id'], + 'note_type' => ['required', 'string', Rule::in(array_keys(config('care.nursing_note_types')))], + 'shift_code' => ['nullable', 'string', Rule::in(array_keys(config('care.staff_shift_codes')))], + 'body' => ['required', 'string', 'max:5000'], + ]); + + $visit = Visit::owned($this->ownerRef($request)) + ->where('organization_id', $this->organization($request)->id) + ->findOrFail($validated['visit_id']); + $this->authorizeBranch($request, (int) $visit->branch_id); + + $docs->addNote( + $visit, + $this->ownerRef($request), + $this->actorRef($request), + $validated['body'], + $validated['note_type'], + $validated['shift_code'] ?? null, + $careUnit->id, + ); + + return redirect() + ->route('care.care-units.notes', $careUnit) + ->with('success', 'Nursing note saved.'); + } + + public function handovers(Request $request, CareUnit $careUnit): View + { + $this->authorizeAbility($request, 'nursing.handover.view'); + $this->authorizeOwner($request, $careUnit); + abort_unless((int) $careUnit->organization_id === (int) $this->organization($request)->id, 404); + + $handovers = WardHandover::owned($this->ownerRef($request)) + ->where('care_unit_id', $careUnit->id) + ->orderByDesc('created_at') + ->limit(30) + ->get(); + + return view('care.nursing.handovers', [ + 'unit' => $careUnit->load('department.branch'), + 'handovers' => $handovers, + 'canWrite' => app(CarePermissions::class)->can($this->member($request), 'nursing.handover.manage'), + 'shiftCodes' => config('care.staff_shift_codes'), + 'statuses' => config('care.ward_handover_statuses'), + ]); + } + + public function createHandover(Request $request, CareUnit $careUnit, NursingDocumentationService $docs): View + { + $this->authorizeAbility($request, 'nursing.handover.manage'); + $this->authorizeOwner($request, $careUnit); + + return view('care.nursing.handover-form', [ + 'unit' => $careUnit->load('department.branch'), + 'handover' => null, + 'patientSummaries' => $docs->defaultPatientSummaries($careUnit, $this->ownerRef($request)), + 'shiftCodes' => config('care.staff_shift_codes'), + ]); + } + + public function storeHandover(Request $request, CareUnit $careUnit, NursingDocumentationService $docs): RedirectResponse + { + $this->authorizeAbility($request, 'nursing.handover.manage'); + $this->authorizeOwner($request, $careUnit); + + $validated = $this->validatedHandover($request); + $complete = $request->boolean('complete'); + + $handover = $docs->createHandover( + $careUnit, + $this->ownerRef($request), + $this->actorRef($request), + $validated, + $complete, + ); + + return redirect() + ->route('care.care-units.handovers', $careUnit) + ->with('success', $complete ? 'Handover completed.' : 'Handover draft saved.'); + } + + public function showHandover(Request $request, CareUnit $careUnit, WardHandover $wardHandover): View + { + $this->authorizeAbility($request, 'nursing.handover.view'); + $this->authorizeOwner($request, $careUnit); + $this->authorizeOwner($request, $wardHandover); + abort_unless((int) $wardHandover->care_unit_id === (int) $careUnit->id, 404); + + return view('care.nursing.handover-show', [ + 'unit' => $careUnit->load('department.branch'), + 'handover' => $wardHandover, + 'shiftCodes' => config('care.staff_shift_codes'), + 'statuses' => config('care.ward_handover_statuses'), + 'canWrite' => app(CarePermissions::class)->can($this->member($request), 'nursing.handover.manage') + && $wardHandover->status === WardHandover::STATUS_DRAFT, + ]); + } + + public function completeHandover( + Request $request, + CareUnit $careUnit, + WardHandover $wardHandover, + NursingDocumentationService $docs, + ): RedirectResponse { + $this->authorizeAbility($request, 'nursing.handover.manage'); + $this->authorizeOwner($request, $careUnit); + $this->authorizeOwner($request, $wardHandover); + abort_unless((int) $wardHandover->care_unit_id === (int) $careUnit->id, 404); + + $validated = $this->validatedHandover($request); + $docs->completeHandover($wardHandover, $this->ownerRef($request), $this->actorRef($request), $validated); + + return redirect() + ->route('care.care-units.handovers', $careUnit) + ->with('success', 'Handover completed.'); + } + + /** + * @return array + */ + protected function validatedHandover(Request $request): array + { + $validated = $request->validate([ + 'from_shift' => ['nullable', 'string', Rule::in(array_keys(config('care.staff_shift_codes')))], + 'to_shift' => ['nullable', 'string', Rule::in(array_keys(config('care.staff_shift_codes')))], + 'received_by' => ['nullable', 'string', 'max:255'], + 'situation' => ['nullable', 'string', 'max:5000'], + 'background' => ['nullable', 'string', 'max:5000'], + 'assessment' => ['nullable', 'string', 'max:5000'], + 'recommendation' => ['nullable', 'string', 'max:5000'], + 'patient_summaries' => ['nullable', 'array'], + 'patient_summaries.*.visit_id' => ['nullable', 'integer'], + 'patient_summaries.*.patient_name' => ['nullable', 'string', 'max:255'], + 'patient_summaries.*.bed' => ['nullable', 'string', 'max:100'], + 'patient_summaries.*.note' => ['nullable', 'string', 'max:2000'], + ]); + + return $validated; + } +} diff --git a/app/Models/MarAdministration.php b/app/Models/MarAdministration.php new file mode 100644 index 0000000..408261c --- /dev/null +++ b/app/Models/MarAdministration.php @@ -0,0 +1,68 @@ + 'datetime', + 'recorded_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(MarOrder::class, 'mar_order_id'); + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function careUnit(): BelongsTo + { + return $this->belongsTo(CareUnit::class, 'care_unit_id'); + } +} diff --git a/app/Models/MarOrder.php b/app/Models/MarOrder.php new file mode 100644 index 0000000..c865a3e --- /dev/null +++ b/app/Models/MarOrder.php @@ -0,0 +1,76 @@ + 'array', + 'is_prn' => 'boolean', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function prescription(): BelongsTo + { + return $this->belongsTo(Prescription::class, 'prescription_id'); + } + + public function prescriptionItem(): BelongsTo + { + return $this->belongsTo(PrescriptionItem::class, 'prescription_item_id'); + } + + public function administrations(): HasMany + { + return $this->hasMany(MarAdministration::class, 'mar_order_id')->orderByDesc('recorded_at'); + } +} diff --git a/app/Models/NursingNote.php b/app/Models/NursingNote.php new file mode 100644 index 0000000..f28a9a3 --- /dev/null +++ b/app/Models/NursingNote.php @@ -0,0 +1,50 @@ + 'datetime', + ]; + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function careUnit(): BelongsTo + { + return $this->belongsTo(CareUnit::class, 'care_unit_id'); + } +} diff --git a/app/Models/WardHandover.php b/app/Models/WardHandover.php new file mode 100644 index 0000000..efd1639 --- /dev/null +++ b/app/Models/WardHandover.php @@ -0,0 +1,54 @@ + 'datetime', + 'patient_summaries' => 'array', + ]; + } + + public function careUnit(): BelongsTo + { + return $this->belongsTo(CareUnit::class, 'care_unit_id'); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } +} diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index 33c9a1f..a975d80 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -584,10 +584,19 @@ class CarePermissions if (in_array('vitals.manage', $abilities, true)) { $abilities[] = 'inpatient.placement.view'; $abilities[] = 'inpatient.placement.manage'; + $abilities[] = 'mar.view'; + $abilities[] = 'mar.administer'; + $abilities[] = 'nursing.notes.view'; + $abilities[] = 'nursing.notes.manage'; + $abilities[] = 'nursing.handover.view'; + $abilities[] = 'nursing.handover.manage'; } if (in_array('analytics.department.view', $abilities, true)) { $abilities[] = 'inpatient.placement.view'; + $abilities[] = 'mar.view'; + $abilities[] = 'nursing.notes.view'; + $abilities[] = 'nursing.handover.view'; } return array_values(array_unique($abilities)); diff --git a/app/Services/Care/MarService.php b/app/Services/Care/MarService.php new file mode 100644 index 0000000..baa1905 --- /dev/null +++ b/app/Services/Care/MarService.php @@ -0,0 +1,339 @@ +> + */ + protected array $defaultTimes = [ + 'od' => ['08:00'], + 'bd' => ['08:00', '20:00'], + 'tds' => ['08:00', '14:00', '20:00'], + 'qds' => ['06:00', '12:00', '18:00', '22:00'], + 'q4h' => ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'], + 'q6h' => ['00:00', '06:00', '12:00', '18:00'], + 'q8h' => ['06:00', '14:00', '22:00'], + 'prn' => [], + 'stat' => [], + 'custom' => ['08:00'], + ]; + + /** + * Sync MAR orders from active/dispensed prescriptions on a visit. + * + * @return Collection + */ + public function syncOrdersForVisit(Visit $visit, string $ownerRef): Collection + { + $prescriptions = Prescription::owned($ownerRef) + ->where('visit_id', $visit->id) + ->whereIn('status', [Prescription::STATUS_ACTIVE, Prescription::STATUS_DISPENSED]) + ->with('items') + ->get(); + + foreach ($prescriptions as $prescription) { + foreach ($prescription->items as $item) { + if ($item->is_procedure) { + continue; + } + $this->upsertOrderFromItem($visit, $prescription, $item, $ownerRef); + } + } + + return MarOrder::owned($ownerRef) + ->where('visit_id', $visit->id) + ->where('status', MarOrder::STATUS_ACTIVE) + ->orderBy('drug_name') + ->get(); + } + + public function upsertOrderFromItem( + Visit $visit, + Prescription $prescription, + PrescriptionItem $item, + string $ownerRef, + ?string $frequencyCode = null, + ): MarOrder { + $parsed = $this->parseFrequency((string) ($item->frequency ?? ''), $frequencyCode); + + return MarOrder::query()->updateOrCreate( + ['prescription_item_id' => $item->id], + [ + 'owner_ref' => $ownerRef, + 'organization_id' => $visit->organization_id, + 'visit_id' => $visit->id, + 'patient_id' => $visit->patient_id, + 'prescription_id' => $prescription->id, + 'drug_name' => $item->name, + 'dosage' => $item->dosage, + 'route' => $item->route, + 'frequency_code' => $parsed['code'], + 'times_of_day' => $parsed['times'], + 'is_prn' => $parsed['is_prn'], + 'status' => MarOrder::STATUS_ACTIVE, + 'starts_at' => $visit->placed_at ?? $visit->checked_in_at ?? now(), + 'instructions' => $item->instructions, + ], + ); + } + + /** + * Due / overdue / PRN slots for placed patients on a care unit for a calendar day. + * + * @return list> + */ + public function unitRoundBoard(CareUnit $unit, string $ownerRef, ?CarbonInterface $day = null): array + { + $day = Carbon::parse($day ?? now())->startOfDay(); + + $visits = Visit::owned($ownerRef) + ->where('organization_id', $unit->organization_id) + ->where('care_unit_id', $unit->id) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->with(['patient', 'bed']) + ->get(); + + $rows = []; + foreach ($visits as $visit) { + $orders = $this->syncOrdersForVisit($visit, $ownerRef); + foreach ($orders as $order) { + foreach ($this->dueSlotsForOrder($order, $day) as $slot) { + $rows[] = $slot + [ + 'visit' => $visit, + 'patient' => $visit->patient, + 'bed' => $visit->bed, + 'order' => $order, + ]; + } + } + } + + usort($rows, function (array $a, array $b) { + $aTime = $a['scheduled_for']?->timestamp ?? PHP_INT_MAX; + $bTime = $b['scheduled_for']?->timestamp ?? PHP_INT_MAX; + if ($aTime === $bTime) { + return strcmp((string) ($a['patient']?->fullName() ?? ''), (string) ($b['patient']?->fullName() ?? '')); + } + + return $aTime <=> $bTime; + }); + + return $rows; + } + + /** + * @return list + */ + public function dueSlotsForOrder(MarOrder $order, CarbonInterface $day): array + { + $day = Carbon::parse($day)->startOfDay(); + $end = $day->copy()->endOfDay(); + + if ($order->status !== MarOrder::STATUS_ACTIVE) { + return []; + } + + if ($order->starts_at && $order->starts_at->gt($end)) { + return []; + } + if ($order->ends_at && $order->ends_at->lt($day)) { + return []; + } + + $existing = MarAdministration::query() + ->where('mar_order_id', $order->id) + ->where(function ($q) use ($day, $end) { + $q->whereBetween('scheduled_for', [$day, $end]) + ->orWhere(function ($q2) use ($day, $end) { + $q2->whereNull('scheduled_for') + ->whereBetween('recorded_at', [$day, $end]); + }); + }) + ->get(); + + if ($order->is_prn || $order->frequency_code === 'prn') { + $givenToday = $existing->where('status', MarAdministration::STATUS_GIVEN); + + return [[ + 'scheduled_for' => null, + 'state' => $givenToday->isNotEmpty() ? 'prn_given' : 'prn_due', + 'administration' => $givenToday->sortByDesc('recorded_at')->first(), + ]]; + } + + if ($order->frequency_code === 'stat') { + $admin = $existing->first(); + + return [[ + 'scheduled_for' => $order->starts_at ?? $day->copy()->setTime(8, 0), + 'state' => $admin ? $admin->status : 'due', + 'administration' => $admin, + ]]; + } + + $times = $order->times_of_day ?: ($this->defaultTimes[$order->frequency_code] ?? ['08:00']); + $slots = []; + foreach ($times as $time) { + [$h, $m] = array_map('intval', explode(':', $time) + [0, 0]); + $scheduled = $day->copy()->setTime($h, $m); + if ($order->starts_at && $scheduled->lt($order->starts_at)) { + continue; + } + + $admin = $existing->first(function (MarAdministration $a) use ($scheduled) { + return $a->scheduled_for && $a->scheduled_for->equalTo($scheduled); + }); + + $state = 'due'; + if ($admin) { + $state = $admin->status; + } elseif ($scheduled->lt(now()->subMinutes(30))) { + $state = 'overdue'; + } + + $slots[] = [ + 'scheduled_for' => $scheduled, + 'state' => $state, + 'administration' => $admin, + ]; + } + + return $slots; + } + + public function recordAdministration( + MarOrder $order, + string $status, + string $ownerRef, + ?string $actorRef, + ?CarbonInterface $scheduledFor = null, + ?string $doseGiven = null, + ?string $route = null, + ?string $witnessBy = null, + ?string $reason = null, + ?string $notes = null, + ?int $careUnitId = null, + ): MarAdministration { + if (! in_array($status, [ + MarAdministration::STATUS_GIVEN, + MarAdministration::STATUS_HELD, + MarAdministration::STATUS_REFUSED, + MarAdministration::STATUS_MISSED, + ], true)) { + throw ValidationException::withMessages(['status' => 'Invalid MAR status.']); + } + + if ($order->status !== MarOrder::STATUS_ACTIVE) { + throw ValidationException::withMessages(['mar_order_id' => 'This MAR order is not active.']); + } + + if (in_array($status, [MarAdministration::STATUS_HELD, MarAdministration::STATUS_REFUSED, MarAdministration::STATUS_MISSED], true) + && blank($reason) && blank($notes)) { + throw ValidationException::withMessages([ + 'reason' => 'Provide a reason when holding, refusing, or marking missed.', + ]); + } + + $visit = $order->visit ?? Visit::query()->findOrFail($order->visit_id); + + if ($scheduledFor && ! $order->is_prn) { + $duplicate = MarAdministration::query() + ->where('mar_order_id', $order->id) + ->where('scheduled_for', $scheduledFor) + ->exists(); + if ($duplicate) { + throw ValidationException::withMessages([ + 'scheduled_for' => 'This dose slot is already recorded.', + ]); + } + } + + $admin = MarAdministration::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $order->organization_id, + 'mar_order_id' => $order->id, + 'visit_id' => $order->visit_id, + 'patient_id' => $order->patient_id, + 'care_unit_id' => $careUnitId ?? $visit->care_unit_id, + 'scheduled_for' => $scheduledFor, + 'recorded_at' => now(), + 'status' => $status, + 'dose_given' => $doseGiven ?? $order->dosage, + 'route' => $route ?? $order->route, + 'administered_by' => $actorRef, + 'witness_by' => $witnessBy, + 'reason' => $reason, + 'notes' => $notes, + ]); + + AuditLogger::record( + $ownerRef, + 'mar.'.$status, + $order->organization_id, + $actorRef, + MarAdministration::class, + $admin->id, + ['mar_order_id' => $order->id, 'visit_id' => $order->visit_id], + ); + + return $admin; + } + + /** + * @return array{code: string, times: list, is_prn: bool} + */ + public function parseFrequency(string $frequency, ?string $override = null): array + { + if ($override && isset($this->defaultTimes[$override])) { + return [ + 'code' => $override, + 'times' => $this->defaultTimes[$override], + 'is_prn' => $override === 'prn', + ]; + } + + $raw = strtolower(trim($frequency)); + $map = [ + 'od' => 'od', 'once daily' => 'od', 'daily' => 'od', 'once a day' => 'od', '1x' => 'od', + 'bd' => 'bd', 'bid' => 'bd', 'twice daily' => 'bd', 'twice a day' => 'bd', '2x' => 'bd', + 'tds' => 'tds', 'tid' => 'tds', 'three times' => 'tds', '3x' => 'tds', + 'qds' => 'qds', 'qid' => 'qds', 'four times' => 'qds', '4x' => 'qds', + 'q4h' => 'q4h', 'every 4' => 'q4h', + 'q6h' => 'q6h', 'every 6' => 'q6h', + 'q8h' => 'q8h', 'every 8' => 'q8h', + 'prn' => 'prn', 'as needed' => 'prn', 'when required' => 'prn', + 'stat' => 'stat', 'immediately' => 'stat', 'now' => 'stat', + ]; + + foreach ($map as $needle => $code) { + if ($raw === $needle || str_contains($raw, $needle)) { + return [ + 'code' => $code, + 'times' => $this->defaultTimes[$code], + 'is_prn' => $code === 'prn', + ]; + } + } + + return [ + 'code' => 'od', + 'times' => $this->defaultTimes['od'], + 'is_prn' => false, + ]; + } +} diff --git a/app/Services/Care/NursingDocumentationService.php b/app/Services/Care/NursingDocumentationService.php new file mode 100644 index 0000000..7ac9b5b --- /dev/null +++ b/app/Services/Care/NursingDocumentationService.php @@ -0,0 +1,165 @@ + $ownerRef, + 'organization_id' => $visit->organization_id, + 'visit_id' => $visit->id, + 'patient_id' => $visit->patient_id, + 'care_unit_id' => $careUnitId ?? $visit->care_unit_id, + 'note_type' => $noteType, + 'shift_code' => $shiftCode, + 'body' => $body, + 'recorded_by' => $actorRef, + 'recorded_at' => now(), + ]); + + AuditLogger::record( + $ownerRef, + 'nursing_note.created', + $visit->organization_id, + $actorRef, + NursingNote::class, + $note->id, + ); + + return $note; + } + + /** + * @return Collection + */ + public function notesForUnit(CareUnit $unit, string $ownerRef, int $limit = 50): Collection + { + return NursingNote::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->with(['patient', 'visit']) + ->orderByDesc('recorded_at') + ->limit($limit) + ->get(); + } + + /** + * @return Collection + */ + public function notesForVisit(Visit $visit, string $ownerRef, int $limit = 50): Collection + { + return NursingNote::owned($ownerRef) + ->where('visit_id', $visit->id) + ->orderByDesc('recorded_at') + ->limit($limit) + ->get(); + } + + /** + * @param array $data + */ + public function createHandover( + CareUnit $unit, + string $ownerRef, + ?string $actorRef, + array $data, + bool $complete = false, + ): WardHandover { + $handover = WardHandover::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $unit->organization_id, + 'care_unit_id' => $unit->id, + 'from_shift' => $data['from_shift'] ?? null, + 'to_shift' => $data['to_shift'] ?? null, + 'handed_over_at' => $complete ? now() : ($data['handed_over_at'] ?? null), + 'handed_over_by' => $actorRef, + 'received_by' => $data['received_by'] ?? null, + 'situation' => $data['situation'] ?? null, + 'background' => $data['background'] ?? null, + 'assessment' => $data['assessment'] ?? null, + 'recommendation' => $data['recommendation'] ?? null, + 'patient_summaries' => $data['patient_summaries'] ?? $this->defaultPatientSummaries($unit, $ownerRef), + 'status' => $complete ? WardHandover::STATUS_COMPLETED : WardHandover::STATUS_DRAFT, + ]); + + AuditLogger::record( + $ownerRef, + $complete ? 'ward_handover.completed' : 'ward_handover.created', + $unit->organization_id, + $actorRef, + WardHandover::class, + $handover->id, + ); + + return $handover; + } + + /** + * @param array $data + */ + public function completeHandover( + WardHandover $handover, + string $ownerRef, + ?string $actorRef, + array $data = [], + ): WardHandover { + $handover->update([ + 'from_shift' => $data['from_shift'] ?? $handover->from_shift, + 'to_shift' => $data['to_shift'] ?? $handover->to_shift, + 'received_by' => $data['received_by'] ?? $handover->received_by, + 'situation' => $data['situation'] ?? $handover->situation, + 'background' => $data['background'] ?? $handover->background, + 'assessment' => $data['assessment'] ?? $handover->assessment, + 'recommendation' => $data['recommendation'] ?? $handover->recommendation, + 'patient_summaries' => $data['patient_summaries'] ?? $handover->patient_summaries, + 'handed_over_at' => now(), + 'handed_over_by' => $actorRef ?? $handover->handed_over_by, + 'status' => WardHandover::STATUS_COMPLETED, + ]); + + AuditLogger::record( + $ownerRef, + 'ward_handover.completed', + $handover->organization_id, + $actorRef, + WardHandover::class, + $handover->id, + ); + + return $handover->fresh(); + } + + /** + * @return list + */ + public function defaultPatientSummaries(CareUnit $unit, string $ownerRef): array + { + $visits = Visit::owned($ownerRef) + ->where('care_unit_id', $unit->id) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->with(['patient', 'bed']) + ->orderBy('placed_at') + ->get(); + + return $visits->map(fn (Visit $visit) => [ + 'visit_id' => $visit->id, + 'patient_name' => $visit->patient?->fullName() ?? 'Unknown', + 'bed' => $visit->bed?->label, + 'note' => '', + ])->all(); + } +} diff --git a/config/care.php b/config/care.php index b2becb8..a4ff032 100644 --- a/config/care.php +++ b/config/care.php @@ -143,6 +143,50 @@ return [ 'rotating' => 'Rotating', ], + 'mar_frequency_codes' => [ + 'od' => 'Once daily (OD)', + 'bd' => 'Twice daily (BD)', + 'tds' => 'Three times daily (TDS)', + 'qds' => 'Four times daily (QDS)', + 'q4h' => 'Every 4 hours', + 'q6h' => 'Every 6 hours', + 'q8h' => 'Every 8 hours', + 'prn' => 'As needed (PRN)', + 'stat' => 'Immediate (STAT)', + 'custom' => 'Custom times', + ], + + 'mar_administration_statuses' => [ + 'given' => 'Given', + 'held' => 'Held', + 'refused' => 'Refused', + 'missed' => 'Missed', + ], + + 'mar_slot_states' => [ + 'due' => 'Due', + 'overdue' => 'Overdue', + 'given' => 'Given', + 'held' => 'Held', + 'refused' => 'Refused', + 'missed' => 'Missed', + 'prn_due' => 'PRN available', + 'prn_given' => 'PRN given today', + ], + + 'nursing_note_types' => [ + 'progress' => 'Progress note', + 'assessment' => 'Nursing assessment', + 'intervention' => 'Intervention', + 'observation' => 'Observation', + 'other' => 'Other', + ], + + 'ward_handover_statuses' => [ + 'draft' => 'Draft', + 'completed' => 'Completed', + ], + /* | Practitioner specialty options (Admin → Practitioners / Team invite). | Stored as free-text labels on care_practitioners.specialty. diff --git a/database/migrations/2026_07_20_140000_create_mar_and_nursing_tables.php b/database/migrations/2026_07_20_140000_create_mar_and_nursing_tables.php new file mode 100644 index 0000000..289f1b4 --- /dev/null +++ b/database/migrations/2026_07_20_140000_create_mar_and_nursing_tables.php @@ -0,0 +1,119 @@ +id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('visit_id')->constrained('care_visits')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('prescription_id')->constrained('care_prescriptions')->cascadeOnDelete(); + $table->foreignId('prescription_item_id')->constrained('care_prescription_items')->cascadeOnDelete(); + $table->string('drug_name'); + $table->string('dosage')->nullable(); + $table->string('route')->nullable(); + /** od|bd|tds|qds|q4h|q6h|q8h|prn|stat|custom */ + $table->string('frequency_code')->default('od'); + $table->json('times_of_day')->nullable(); + $table->boolean('is_prn')->default(false); + /** active|paused|stopped */ + $table->string('status')->default('active'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->text('instructions')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['prescription_item_id']); + $table->index(['visit_id', 'status']); + $table->index(['organization_id', 'status']); + }); + + Schema::create('care_mar_administrations', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('mar_order_id')->constrained('care_mar_orders')->cascadeOnDelete(); + $table->foreignId('visit_id')->constrained('care_visits')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('care_unit_id')->nullable()->constrained('care_care_units')->nullOnDelete(); + $table->timestamp('scheduled_for')->nullable(); + $table->timestamp('recorded_at'); + /** given|held|refused|missed */ + $table->string('status'); + $table->string('dose_given')->nullable(); + $table->string('route')->nullable(); + $table->string('administered_by')->nullable(); + $table->string('witness_by')->nullable(); + $table->string('reason')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['visit_id', 'recorded_at']); + $table->index(['care_unit_id', 'scheduled_for']); + $table->index(['mar_order_id', 'status']); + }); + + Schema::create('care_nursing_notes', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('visit_id')->constrained('care_visits')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('care_unit_id')->nullable()->constrained('care_care_units')->nullOnDelete(); + /** progress|assessment|intervention|observation|other */ + $table->string('note_type')->default('progress'); + $table->string('shift_code')->nullable(); + $table->text('body'); + $table->string('recorded_by')->nullable(); + $table->timestamp('recorded_at'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['visit_id', 'recorded_at']); + $table->index(['care_unit_id', 'recorded_at']); + }); + + Schema::create('care_ward_handovers', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('care_unit_id')->constrained('care_care_units')->cascadeOnDelete(); + $table->string('from_shift')->nullable(); + $table->string('to_shift')->nullable(); + $table->timestamp('handed_over_at')->nullable(); + $table->string('handed_over_by')->nullable(); + $table->string('received_by')->nullable(); + $table->text('situation')->nullable(); + $table->text('background')->nullable(); + $table->text('assessment')->nullable(); + $table->text('recommendation')->nullable(); + $table->json('patient_summaries')->nullable(); + /** draft|completed */ + $table->string('status')->default('draft'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['care_unit_id', 'status']); + $table->index(['care_unit_id', 'handed_over_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_ward_handovers'); + Schema::dropIfExists('care_nursing_notes'); + Schema::dropIfExists('care_mar_administrations'); + Schema::dropIfExists('care_mar_orders'); + } +}; diff --git a/resources/views/care/admin/care-units/show.blade.php b/resources/views/care/admin/care-units/show.blade.php index 6ca01a9..7c03c0c 100644 --- a/resources/views/care/admin/care-units/show.blade.php +++ b/resources/views/care/admin/care-units/show.blade.php @@ -24,7 +24,19 @@ : null; $permissions = app(\App\Services\Care\CarePermissions::class); $canManageUnit = $permissions->can($member, 'admin.departments.manage'); + $canMar = $permissions->can($member, 'mar.view'); + $canNotes = $permissions->can($member, 'nursing.notes.view'); + $canHandover = $permissions->can($member, 'nursing.handover.view'); @endphp + @if ($canMar) + MAR board + @endif + @if ($canNotes) + Nursing notes + @endif + @if ($canHandover) + Handovers + @endif @if ($canManageUnit) Edit unit Assign staff @@ -37,7 +49,7 @@

Patients on this unit

-

Active visit placements — foundation for MAR and ward boards.

+

Active visit placements — open MAR, notes, and handovers from the actions above.

{{ $placedVisits->count() }} placed

@@ -64,6 +76,9 @@ {{ $visit->bed?->label ?? '—' }} {{ $visit->placed_at?->format('d M Y H:i') ?? '—' }} + @if ($canMar ?? false) + MAR + @endif @if ($canPlace)
@csrf @method('DELETE') diff --git a/resources/views/care/nursing/handover-form.blade.php b/resources/views/care/nursing/handover-form.blade.php new file mode 100644 index 0000000..ad07c29 --- /dev/null +++ b/resources/views/care/nursing/handover-form.blade.php @@ -0,0 +1,83 @@ +@php + $summaries = old('patient_summaries', $patientSummaries ?? $handover?->patient_summaries ?? []); +@endphp + + +
+
+

+ Handovers + / + New +

+

SBAR ward handover

+
+ + + @csrf +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+

Patients on unit

+
+ @forelse ($summaries as $i => $summary) +
+ + + +

{{ $summary['patient_name'] ?? 'Patient' }} @if (!empty($summary['bed'])) · {{ $summary['bed'] }} @endif

+ +
+ @empty +

No placed patients to include.

+ @endforelse +
+
+ +
+ + +
+ +
+
diff --git a/resources/views/care/nursing/handover-show.blade.php b/resources/views/care/nursing/handover-show.blade.php new file mode 100644 index 0000000..4960788 --- /dev/null +++ b/resources/views/care/nursing/handover-show.blade.php @@ -0,0 +1,73 @@ + +
+
+

+ Handovers + / + {{ $statuses[$handover->status] ?? $handover->status }} +

+

+ {{ $shiftCodes[$handover->from_shift] ?? ($handover->from_shift ?: 'Shift') }} + → + {{ $shiftCodes[$handover->to_shift] ?? ($handover->to_shift ?: 'Shift') }} +

+

+ {{ ($handover->handed_over_at ?? $handover->created_at)?->format('d M Y H:i') }} + @if ($handover->received_by) · Received by {{ $handover->received_by }} @endif +

+
+ +
+
+

Situation

+

{{ $handover->situation ?: '—' }}

+
+
+

Background

+

{{ $handover->background ?: '—' }}

+
+
+

Assessment

+

{{ $handover->assessment ?: '—' }}

+
+
+

Recommendation

+

{{ $handover->recommendation ?: '—' }}

+
+
+ +
+

Patient summaries

+
+ @forelse ($handover->patient_summaries ?? [] as $summary) +
+

{{ $summary['patient_name'] ?? 'Patient' }} @if (!empty($summary['bed'])) · {{ $summary['bed'] }} @endif

+

{{ $summary['note'] ?: '—' }}

+
+ @empty +

No patient lines.

+ @endforelse +
+
+ + @if ($canWrite) +
+ @csrf + + + + + + + + @foreach ($handover->patient_summaries ?? [] as $i => $summary) + + + + + @endforeach + +
+ @endif +
+
diff --git a/resources/views/care/nursing/handovers.blade.php b/resources/views/care/nursing/handovers.blade.php new file mode 100644 index 0000000..5b4a2e3 --- /dev/null +++ b/resources/views/care/nursing/handovers.blade.php @@ -0,0 +1,51 @@ + +
+
+
+

+ {{ $unit->name }} + / + Ward handovers +

+

Shift handovers

+

SBAR handovers scoped to this care unit.

+
+ @if ($canWrite) + New handover + @endif +
+ +
+ + + + + + + + + + + @forelse ($handovers as $handover) + + + + + + + @empty + + @endforelse + +
WhenShiftsStatus
+ {{ ($handover->handed_over_at ?? $handover->created_at)?->format('d M Y H:i') }} + + {{ $shiftCodes[$handover->from_shift] ?? ($handover->from_shift ?: '—') }} + → + {{ $shiftCodes[$handover->to_shift] ?? ($handover->to_shift ?: '—') }} + {{ $statuses[$handover->status] ?? $handover->status }} + Open +
No handovers yet.
+
+
+
diff --git a/resources/views/care/nursing/mar-board.blade.php b/resources/views/care/nursing/mar-board.blade.php new file mode 100644 index 0000000..3c34abf --- /dev/null +++ b/resources/views/care/nursing/mar-board.blade.php @@ -0,0 +1,99 @@ +@php + $stateClass = [ + 'due' => 'bg-amber-50 text-amber-800', + 'overdue' => 'bg-red-50 text-red-800', + 'given' => 'bg-emerald-50 text-emerald-800', + 'held' => 'bg-slate-100 text-slate-700', + 'refused' => 'bg-orange-50 text-orange-800', + 'missed' => 'bg-red-50 text-red-700', + 'prn_due' => 'bg-sky-50 text-sky-800', + 'prn_given' => 'bg-emerald-50 text-emerald-700', + ]; +@endphp + + +
+
+
+

+ {{ $unit->name }} + / + Medication round +

+

MAR board

+

Due doses for patients placed on this unit.

+
+
+
+ + +
+ +
+
+ +
+ + + + + + + + + + + + + @forelse ($rows as $row) + @php + $order = $row['order']; + $state = $row['state']; + $scheduled = $row['scheduled_for']; + $done = in_array($state, ['given', 'held', 'refused', 'missed', 'prn_given'], true); + @endphp + + + + + + + + + @empty + + @endforelse + +
TimePatient / bedDrugDose / routeStatus
+ {{ $scheduled?->format('H:i') ?? 'PRN' }} + + {{ $row['patient']?->fullName() }} +
{{ $row['bed']?->label ?? 'No bed' }}
+
+ {{ $order->drug_name }} +
{{ strtoupper($order->frequency_code) }}
+
+ {{ $order->dosage ?: '—' }} +
{{ $order->route ?: '—' }}
+
+ + {{ $stateLabels[$state] ?? $state }} + + + Chart + @if ($canAdminister && ! $done) +
+ @csrf + + @if ($scheduled) + + @endif + + + +
+ @endif +
No MAR doses for this date. Place patients and ensure active prescriptions exist.
+
+
+
diff --git a/resources/views/care/nursing/mar-visit.blade.php b/resources/views/care/nursing/mar-visit.blade.php new file mode 100644 index 0000000..3322b4e --- /dev/null +++ b/resources/views/care/nursing/mar-visit.blade.php @@ -0,0 +1,110 @@ +@php + $stateClass = [ + 'due' => 'bg-amber-50 text-amber-800', + 'overdue' => 'bg-red-50 text-red-800', + 'given' => 'bg-emerald-50 text-emerald-800', + 'held' => 'bg-slate-100 text-slate-700', + 'refused' => 'bg-orange-50 text-orange-800', + 'missed' => 'bg-red-50 text-red-700', + 'prn_due' => 'bg-sky-50 text-sky-800', + 'prn_given' => 'bg-emerald-50 text-emerald-700', + ]; +@endphp + + +
+
+
+

+ @if ($visit->careUnit) + {{ $visit->careUnit->name }} MAR + / + @endif + Patient chart +

+

{{ $visit->patient?->fullName() }}

+

+ {{ $visit->careUnit?->name ?? 'Not placed' }} + @if ($visit->bed) · Bed {{ $visit->bed->label }} @endif +

+
+
+
+ + +
+ +
+
+ +
+ @forelse ($chart as $block) + @php $order = $block['order']; @endphp +
+
+
+

{{ $order->drug_name }}

+

+ {{ $order->dosage ?: 'Dose not set' }} + · {{ $order->route ?: 'Route n/a' }} + · {{ $frequencies[$order->frequency_code] ?? strtoupper($order->frequency_code) }} +

+ @if ($order->instructions) +

{{ $order->instructions }}

+ @endif +
+
+ +
+ + + + + + + + + + @foreach ($block['slots'] as $slot) + @php + $state = $slot['state']; + $done = in_array($state, ['given', 'held', 'refused', 'missed', 'prn_given'], true); + @endphp + + + + + + @endforeach + +
ScheduledState
{{ $slot['scheduled_for']?->format('d M Y H:i') ?? 'PRN' }} + + {{ $stateLabels[$state] ?? $state }} + + @if ($slot['administration']?->reason) +
{{ $slot['administration']->reason }}
+ @endif +
+ @if ($canAdminister && ! $done) +
+ @csrf + @if ($slot['scheduled_for']) + + @endif + + + + +
+ @endif +
+
+
+ @empty +
+ No active medication orders on this visit. Prescribe and activate medications first; dispensed Rxs also sync to MAR. +
+ @endforelse +
+
+
diff --git a/resources/views/care/nursing/notes.blade.php b/resources/views/care/nursing/notes.blade.php new file mode 100644 index 0000000..3f46148 --- /dev/null +++ b/resources/views/care/nursing/notes.blade.php @@ -0,0 +1,82 @@ + +
+
+

+ {{ $unit->name }} + / + Nursing notes +

+

Nursing notes

+

Chronologic unit documentation — not overwritten like specialty forms.

+
+ + @if ($canWrite) +
+ @csrf +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ @endif + +
+ + + + + + + + + + + @forelse ($notes as $note) + + + + + + + @empty + + @endforelse + +
WhenPatientTypeNote
{{ $note->recorded_at?->format('d M H:i') }}{{ $note->patient?->fullName() }}{{ $noteTypes[$note->note_type] ?? $note->note_type }}{{ $note->body }}
No nursing notes yet.
+
+
+
diff --git a/routes/web.php b/routes/web.php index 2e5a975..0fb3913 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,8 @@ use App\Http\Controllers\Care\CareUnitController; use App\Http\Controllers\Care\BedController; use App\Http\Controllers\Care\StaffAssignmentController; use App\Http\Controllers\Care\VisitPlacementController; +use App\Http\Controllers\Care\MarController; +use App\Http\Controllers\Care\NursingDocumentationController; use App\Http\Controllers\Care\DeviceController; use App\Http\Controllers\Care\DisplayPublicController; use App\Http\Controllers\Care\DisplayScreenController; @@ -433,6 +435,18 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/visits/{visit}/placement/transfer', [VisitPlacementController::class, 'transfer'])->name('care.visits.placement.transfer'); Route::delete('/visits/{visit}/placement', [VisitPlacementController::class, 'destroy'])->name('care.visits.placement.destroy'); + Route::get('/care-units/{careUnit}/mar', [MarController::class, 'unitBoard'])->name('care.care-units.mar'); + Route::get('/visits/{visit}/mar', [MarController::class, 'visitChart'])->name('care.visits.mar'); + Route::post('/mar/orders/{marOrder}/administer', [MarController::class, 'administer'])->name('care.mar.administer'); + + Route::get('/care-units/{careUnit}/notes', [NursingDocumentationController::class, 'unitNotes'])->name('care.care-units.notes'); + Route::post('/care-units/{careUnit}/notes', [NursingDocumentationController::class, 'storeNote'])->name('care.care-units.notes.store'); + Route::get('/care-units/{careUnit}/handovers', [NursingDocumentationController::class, 'handovers'])->name('care.care-units.handovers'); + Route::get('/care-units/{careUnit}/handovers/create', [NursingDocumentationController::class, 'createHandover'])->name('care.care-units.handovers.create'); + Route::post('/care-units/{careUnit}/handovers', [NursingDocumentationController::class, 'storeHandover'])->name('care.care-units.handovers.store'); + Route::get('/care-units/{careUnit}/handovers/{wardHandover}', [NursingDocumentationController::class, 'showHandover'])->name('care.care-units.handovers.show'); + Route::post('/care-units/{careUnit}/handovers/{wardHandover}/complete', [NursingDocumentationController::class, 'completeHandover'])->name('care.care-units.handovers.complete'); + Route::get('/staff-assignments', [StaffAssignmentController::class, 'index'])->name('care.staff-assignments.index'); Route::get('/staff-assignments/create', [StaffAssignmentController::class, 'create'])->name('care.staff-assignments.create'); Route::post('/staff-assignments', [StaffAssignmentController::class, 'store'])->name('care.staff-assignments.store'); diff --git a/tests/Feature/CareMarAndNursingTest.php b/tests/Feature/CareMarAndNursingTest.php new file mode 100644 index 0000000..0eda451 --- /dev/null +++ b/tests/Feature/CareMarAndNursingTest.php @@ -0,0 +1,268 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'mar-nursing-owner', + 'name' => 'Owner', + 'email' => 'mar-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'MAR Hospital', + 'slug' => 'mar-hospital', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'hospital', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $department = Department::create([ + 'owner_ref' => $this->owner->public_id, + 'branch_id' => $this->branch->id, + 'name' => 'Medicine', + 'type' => 'general', + 'is_active' => true, + ]); + + $this->unit = CareUnit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'department_id' => $department->id, + 'name' => 'Medical Ward', + 'kind' => 'inpatient', + 'is_active' => true, + ]); + + $bed = Bed::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'care_unit_id' => $this->unit->id, + 'label' => 'MW-01', + 'status' => 'available', + 'is_active' => true, + ]); + + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-MAR-1', + 'first_name' => 'Kofi', + 'last_name' => 'Asante', + 'gender' => 'male', + ]); + + $this->visit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + ]); + + app(VisitPlacementService::class)->place( + $this->visit, + $this->unit, + $bed, + $this->owner->public_id, + $this->owner->public_id, + ); + + $rx = Prescription::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'visit_id' => $this->visit->id, + 'patient_id' => $patient->id, + 'status' => Prescription::STATUS_ACTIVE, + 'prescribed_by' => $this->owner->public_id, + ]); + + $this->item = PrescriptionItem::create([ + 'owner_ref' => $this->owner->public_id, + 'prescription_id' => $rx->id, + 'name' => 'Paracetamol', + 'dosage' => '1g', + 'frequency' => 'TDS', + 'route' => 'oral', + 'sort_order' => 1, + ]); + } + + public function test_mar_syncs_orders_and_records_given(): void + { + $mar = app(MarService::class); + $orders = $mar->syncOrdersForVisit($this->visit->fresh(), $this->owner->public_id); + $this->assertCount(1, $orders); + $this->assertSame('tds', $orders->first()->frequency_code); + + $order = $orders->first(); + $scheduled = now()->startOfDay()->setTime(8, 0); + + $this->actingAs($this->owner) + ->post(route('care.mar.administer', $order), [ + 'status' => 'given', + 'scheduled_for' => $scheduled->toDateTimeString(), + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('care_mar_administrations', [ + 'mar_order_id' => $order->id, + 'status' => MarAdministration::STATUS_GIVEN, + ]); + + $this->actingAs($this->owner) + ->get(route('care.care-units.mar', $this->unit)) + ->assertOk() + ->assertSee('Paracetamol') + ->assertSee('Kofi'); + } + + public function test_hold_requires_reason(): void + { + $order = app(MarService::class) + ->syncOrdersForVisit($this->visit->fresh(), $this->owner->public_id) + ->first(); + + $this->actingAs($this->owner) + ->from(route('care.visits.mar', $this->visit)) + ->post(route('care.mar.administer', $order), [ + 'status' => 'held', + 'scheduled_for' => now()->startOfDay()->setTime(8, 0)->toDateTimeString(), + ]) + ->assertRedirect() + ->assertSessionHasErrors('reason'); + } + + public function test_nursing_note_and_handover(): void + { + $this->actingAs($this->owner) + ->post(route('care.care-units.notes.store', $this->unit), [ + 'visit_id' => $this->visit->id, + 'note_type' => 'progress', + 'shift_code' => 'day', + 'body' => 'Vitals stable; pain controlled.', + ]) + ->assertRedirect(route('care.care-units.notes', $this->unit)); + + $this->assertDatabaseHas('care_nursing_notes', [ + 'visit_id' => $this->visit->id, + 'body' => 'Vitals stable; pain controlled.', + ]); + + $this->actingAs($this->owner) + ->post(route('care.care-units.handovers.store', $this->unit), [ + 'from_shift' => 'day', + 'to_shift' => 'night', + 'situation' => 'Quiet ward', + 'background' => '4 patients', + 'assessment' => 'Stable', + 'recommendation' => 'Continue observations', + 'complete' => '1', + ]) + ->assertRedirect(route('care.care-units.handovers', $this->unit)); + + $this->assertDatabaseHas('care_ward_handovers', [ + 'care_unit_id' => $this->unit->id, + 'status' => WardHandover::STATUS_COMPLETED, + 'from_shift' => 'day', + 'to_shift' => 'night', + ]); + + $this->actingAs($this->owner) + ->get(route('care.care-units.notes', $this->unit)) + ->assertOk() + ->assertSee('Vitals stable'); + + $this->actingAs($this->owner) + ->get(route('care.care-units.handovers', $this->unit)) + ->assertOk() + ->assertSee('Completed'); + } + + public function test_duplicate_scheduled_dose_rejected(): void + { + $order = app(MarService::class) + ->syncOrdersForVisit($this->visit->fresh(), $this->owner->public_id) + ->first(); + $scheduled = now()->startOfDay()->setTime(8, 0)->toDateTimeString(); + + $this->actingAs($this->owner) + ->post(route('care.mar.administer', $order), [ + 'status' => 'given', + 'scheduled_for' => $scheduled, + ]) + ->assertRedirect(); + + $this->actingAs($this->owner) + ->from(route('care.visits.mar', $this->visit)) + ->post(route('care.mar.administer', $order), [ + 'status' => 'given', + 'scheduled_for' => $scheduled, + ]) + ->assertRedirect() + ->assertSessionHasErrors('scheduled_for'); + + $this->assertSame(1, MarAdministration::query()->where('mar_order_id', $order->id)->count()); + $this->assertSame(1, MarOrder::query()->where('visit_id', $this->visit->id)->count()); + } +}