diff --git a/app/Http/Controllers/Care/CareUnitController.php b/app/Http/Controllers/Care/CareUnitController.php index 19c3660..5d16cf0 100644 --- a/app/Http/Controllers/Care/CareUnitController.php +++ b/app/Http/Controllers/Care/CareUnitController.php @@ -6,7 +6,9 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Models\CareUnit; use App\Models\Department; +use App\Models\Visit; use App\Services\Care\AuditLogger; +use App\Services\Care\CarePermissions; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -18,7 +20,7 @@ class CareUnitController extends Controller public function index(Request $request): View { - $this->authorizeAbility($request, 'admin.departments.view'); + $this->authorizeCareUnitView($request); $organization = $this->organization($request); $owner = $this->ownerRef($request); @@ -98,18 +100,63 @@ class CareUnitController extends Controller public function show(Request $request, CareUnit $careUnit): View { - $this->authorizeAbility($request, 'admin.departments.view'); + $this->authorizeCareUnitView($request); $this->authorizeOwner($request, $careUnit); + $organization = $this->organization($request); + abort_unless((int) $careUnit->organization_id === (int) $organization->id, 404); $careUnit->load(['department.branch', 'beds']); + $placedVisits = Visit::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->where('care_unit_id', $careUnit->id) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->with(['patient', 'bed']) + ->orderBy('placed_at') + ->get(); + + $placeableVisits = Visit::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->where('branch_id', $careUnit->department?->branch_id) + ->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS]) + ->where(function ($q) use ($careUnit) { + $q->whereNull('care_unit_id') + ->orWhere('care_unit_id', '!=', $careUnit->id); + }) + ->with(['patient', 'careUnit']) + ->orderByDesc('checked_in_at') + ->limit(50) + ->get(); + + $availableBeds = $careUnit->beds + ->where('is_active', true) + ->whereIn('status', ['available', 'reserved']) + ->values(); + + $canPlace = app(CarePermissions::class)->can($this->member($request), 'inpatient.placement.manage'); + return view('care.admin.care-units.show', [ 'unit' => $careUnit, 'kinds' => config('care.care_unit_kinds'), 'bedStatuses' => config('care.bed_statuses'), + 'placedVisits' => $placedVisits, + 'placeableVisits' => $placeableVisits, + 'availableBeds' => $availableBeds, + 'canPlace' => $canPlace, ]); } + protected function authorizeCareUnitView(Request $request): void + { + $permissions = app(CarePermissions::class); + $member = $this->member($request); + abort_unless( + $permissions->can($member, 'admin.departments.view') + || $permissions->can($member, 'inpatient.placement.view'), + 403, + ); + } + public function edit(Request $request, CareUnit $careUnit): View { $this->authorizeAbility($request, 'admin.departments.manage'); diff --git a/app/Http/Controllers/Care/VisitPlacementController.php b/app/Http/Controllers/Care/VisitPlacementController.php new file mode 100644 index 0000000..298d1c5 --- /dev/null +++ b/app/Http/Controllers/Care/VisitPlacementController.php @@ -0,0 +1,118 @@ +authorizeAbility($request, 'inpatient.placement.manage'); + $this->authorizeOwner($request, $careUnit); + $organization = $this->organization($request); + abort_unless((int) $careUnit->organization_id === (int) $organization->id, 404); + + $validated = $request->validate([ + 'visit_id' => ['required', 'integer', 'exists:care_visits,id'], + 'bed_id' => ['nullable', 'integer', 'exists:care_beds,id'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]); + + $visit = Visit::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->findOrFail($validated['visit_id']); + $this->authorizeBranch($request, (int) $visit->branch_id); + + $bed = null; + if (! empty($validated['bed_id'])) { + $bed = Bed::owned($this->ownerRef($request)) + ->where('care_unit_id', $careUnit->id) + ->findOrFail($validated['bed_id']); + } + + $placements->place( + $visit, + $careUnit, + $bed, + $this->ownerRef($request), + $this->actorRef($request), + $validated['notes'] ?? null, + ); + + return redirect() + ->route('care.care-units.show', $careUnit) + ->with('success', 'Patient placed on this care unit.'); + } + + public function transfer(Request $request, Visit $visit, VisitPlacementService $placements): RedirectResponse + { + $this->authorizeAbility($request, 'inpatient.placement.manage'); + $this->authorizeOwner($request, $visit); + $this->authorizeBranch($request, (int) $visit->branch_id); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'care_unit_id' => ['required', 'integer', 'exists:care_care_units,id'], + 'bed_id' => ['nullable', 'integer', 'exists:care_beds,id'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]); + + $unit = CareUnit::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->findOrFail($validated['care_unit_id']); + + $bed = null; + if (! empty($validated['bed_id'])) { + $bed = Bed::owned($this->ownerRef($request)) + ->where('care_unit_id', $unit->id) + ->findOrFail($validated['bed_id']); + } + + $placements->transfer( + $visit, + $unit, + $bed, + $this->ownerRef($request), + $this->actorRef($request), + $validated['notes'] ?? null, + ); + + return redirect() + ->route('care.care-units.show', $unit) + ->with('success', 'Patient transferred.'); + } + + public function destroy(Request $request, Visit $visit, VisitPlacementService $placements): RedirectResponse + { + $this->authorizeAbility($request, 'inpatient.placement.manage'); + $this->authorizeOwner($request, $visit); + $this->authorizeBranch($request, (int) $visit->branch_id); + + $unitId = $visit->care_unit_id; + + $placements->dischargePlacement( + $visit, + $this->ownerRef($request), + $this->actorRef($request), + $request->input('notes'), + ); + + if ($unitId) { + return redirect() + ->route('care.care-units.show', $unitId) + ->with('success', 'Placement discharged; bed freed if applicable.'); + } + + return back()->with('success', 'Placement discharged.'); + } +} diff --git a/app/Models/Bed.php b/app/Models/Bed.php index 9b428d0..02974d5 100644 --- a/app/Models/Bed.php +++ b/app/Models/Bed.php @@ -5,6 +5,8 @@ namespace App\Models; use App\Models\Concerns\BelongsToOwner; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; class Bed extends Model @@ -41,4 +43,18 @@ class Bed extends Model { return $this->belongsTo(CareUnit::class, 'care_unit_id'); } + + public function stays(): HasMany + { + return $this->hasMany(BedStay::class, 'bed_id'); + } + + public function activeStay(): HasOne + { + return $this->hasOne(BedStay::class, 'bed_id') + ->ofMany( + ['id' => 'max'], + fn ($query) => $query->where('status', BedStay::STATUS_ACTIVE), + ); + } } diff --git a/app/Models/BedStay.php b/app/Models/BedStay.php new file mode 100644 index 0000000..2d5806b --- /dev/null +++ b/app/Models/BedStay.php @@ -0,0 +1,67 @@ + 'datetime', + 'discharged_at' => 'datetime', + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function careUnit(): BelongsTo + { + return $this->belongsTo(CareUnit::class, 'care_unit_id'); + } + + public function bed(): BelongsTo + { + return $this->belongsTo(Bed::class, 'bed_id'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('status', self::STATUS_ACTIVE); + } +} diff --git a/app/Models/CareUnit.php b/app/Models/CareUnit.php index 19dc0ee..ae82ec3 100644 --- a/app/Models/CareUnit.php +++ b/app/Models/CareUnit.php @@ -53,6 +53,16 @@ class CareUnit extends Model return $this->hasMany(StaffAssignment::class, 'care_unit_id'); } + public function visits(): HasMany + { + return $this->hasMany(Visit::class, 'care_unit_id'); + } + + public function activeStays(): HasMany + { + return $this->hasMany(BedStay::class, 'care_unit_id')->where('status', BedStay::STATUS_ACTIVE); + } + public function supportsBeds(): bool { return $this->kind === 'inpatient'; diff --git a/app/Models/Visit.php b/app/Models/Visit.php index 66f77da..46622a3 100644 --- a/app/Models/Visit.php +++ b/app/Models/Visit.php @@ -24,6 +24,7 @@ class Visit extends Model protected $fillable = [ 'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id', + 'care_unit_id', 'bed_id', 'placed_at', 'placed_by', 'status', 'specialty_stage', 'checked_in_at', 'completed_at', 'checked_in_by', ]; @@ -32,6 +33,7 @@ class Visit extends Model return [ 'checked_in_at' => 'datetime', 'completed_at' => 'datetime', + 'placed_at' => 'datetime', ]; } @@ -64,6 +66,30 @@ class Visit extends Model return $this->belongsTo(Organization::class, 'organization_id'); } + public function careUnit(): BelongsTo + { + return $this->belongsTo(CareUnit::class, 'care_unit_id'); + } + + public function bed(): BelongsTo + { + return $this->belongsTo(Bed::class, 'bed_id'); + } + + public function bedStays(): HasMany + { + return $this->hasMany(BedStay::class, 'visit_id'); + } + + public function activeBedStay(): HasOne + { + return $this->hasOne(BedStay::class, 'visit_id') + ->ofMany( + ['id' => 'max'], + fn ($query) => $query->where('status', BedStay::STATUS_ACTIVE), + ); + } + public function appointment(): HasOne { return $this->hasOne(Appointment::class, 'visit_id'); diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index c18ef84..33c9a1f 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -566,7 +566,31 @@ class CarePermissions } } - return array_keys($merged); + return $this->withDerivedAbilities(array_keys($merged)); + } + + /** + * Floor clinicians who record vitals may also place patients on care units/beds. + * + * @param list $abilities + * @return list + */ + protected function withDerivedAbilities(array $abilities): array + { + if (in_array('*', $abilities, true)) { + return $abilities; + } + + if (in_array('vitals.manage', $abilities, true)) { + $abilities[] = 'inpatient.placement.view'; + $abilities[] = 'inpatient.placement.manage'; + } + + if (in_array('analytics.department.view', $abilities, true)) { + $abilities[] = 'inpatient.placement.view'; + } + + return array_values(array_unique($abilities)); } public function isAdmin(?Member $member): bool diff --git a/app/Services/Care/PatientService.php b/app/Services/Care/PatientService.php index 6afec04..c5b09e1 100644 --- a/app/Services/Care/PatientService.php +++ b/app/Services/Care/PatientService.php @@ -215,7 +215,7 @@ class PatientService ]); $visits = $patient->visits() - ->with(['branch']) + ->with(['branch', 'careUnit', 'bed']) ->orderByDesc('checked_in_at') ->limit(10) ->get(); diff --git a/app/Services/Care/VisitPlacementService.php b/app/Services/Care/VisitPlacementService.php new file mode 100644 index 0000000..fee7f30 --- /dev/null +++ b/app/Services/Care/VisitPlacementService.php @@ -0,0 +1,245 @@ +assertCompatible($visit, $unit, $bed); + + return DB::transaction(function () use ($visit, $unit, $bed, $ownerRef, $actorRef, $notes) { + $hadPlacement = $visit->care_unit_id !== null; + $this->endCurrentStay($visit, $ownerRef, $actorRef, $hadPlacement ? 'transfer' : 'admit', $notes); + + if ($bed) { + $this->occupyBed($bed); + } + + $stay = BedStay::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $visit->organization_id, + 'visit_id' => $visit->id, + 'care_unit_id' => $unit->id, + 'bed_id' => $bed?->id, + 'status' => BedStay::STATUS_ACTIVE, + 'admitted_at' => now(), + 'admitted_by' => $actorRef, + 'reason' => $hadPlacement ? 'transfer' : 'admit', + 'notes' => $notes, + ]); + + $visit->update([ + 'care_unit_id' => $unit->id, + 'bed_id' => $bed?->id, + 'placed_at' => now(), + 'placed_by' => $actorRef, + ]); + + AuditLogger::record( + $ownerRef, + $hadPlacement ? 'visit.transferred' : 'visit.placed', + $visit->organization_id, + $actorRef, + Visit::class, + $visit->id, + [ + 'care_unit_id' => $unit->id, + 'bed_id' => $bed?->id, + 'bed_stay_id' => $stay->id, + ], + ); + + return $stay->fresh(['careUnit', 'bed', 'visit']); + }); + } + + public function transfer( + Visit $visit, + CareUnit $unit, + ?Bed $bed, + string $ownerRef, + ?string $actorRef = null, + ?string $notes = null, + ): BedStay { + if ($visit->care_unit_id === null) { + throw ValidationException::withMessages([ + 'care_unit_id' => 'Visit is not currently placed. Use admit/place instead.', + ]); + } + + return $this->place($visit, $unit, $bed, $ownerRef, $actorRef, $notes); + } + + /** + * Clear unit/bed placement without completing the visit. + */ + public function dischargePlacement( + Visit $visit, + string $ownerRef, + ?string $actorRef = null, + ?string $notes = null, + ): void { + $visit->refresh(); + + if ($visit->care_unit_id === null && $visit->bed_id === null) { + return; + } + + DB::transaction(function () use ($visit, $ownerRef, $actorRef, $notes) { + $this->endCurrentStay($visit, $ownerRef, $actorRef, 'discharge', $notes); + + $visit->update([ + 'care_unit_id' => null, + 'bed_id' => null, + 'placed_at' => null, + 'placed_by' => null, + ]); + + AuditLogger::record( + $ownerRef, + 'visit.placement_discharged', + $visit->organization_id, + $actorRef, + Visit::class, + $visit->id, + ); + }); + } + + protected function endCurrentStay( + Visit $visit, + string $ownerRef, + ?string $actorRef, + string $reason, + ?string $notes, + ): void { + $active = BedStay::query() + ->where('visit_id', $visit->id) + ->active() + ->lockForUpdate() + ->get(); + + foreach ($active as $stay) { + $stay->update([ + 'status' => BedStay::STATUS_ENDED, + 'discharged_at' => now(), + 'discharged_by' => $actorRef, + 'reason' => $reason, + 'notes' => $notes ?? $stay->notes, + ]); + + if ($stay->bed_id) { + $bed = Bed::query()->lockForUpdate()->find($stay->bed_id); + if ($bed && $bed->status === 'occupied') { + $stillOccupied = BedStay::query() + ->where('bed_id', $bed->id) + ->active() + ->where('id', '!=', $stay->id) + ->exists(); + + if (! $stillOccupied) { + $bed->update(['status' => 'available']); + } + } + } + } + + // Also free visit's current bed if stay rows were missing. + if ($visit->bed_id) { + $bed = Bed::query()->lockForUpdate()->find($visit->bed_id); + if ($bed && $bed->status === 'occupied') { + $stillOccupied = BedStay::query() + ->where('bed_id', $bed->id) + ->active() + ->exists(); + if (! $stillOccupied) { + $bed->update(['status' => 'available']); + } + } + } + } + + protected function occupyBed(Bed $bed): void + { + $bed = Bed::query()->lockForUpdate()->findOrFail($bed->id); + + $conflict = BedStay::query() + ->where('bed_id', $bed->id) + ->active() + ->exists(); + + if ($conflict || in_array($bed->status, ['occupied', 'maintenance', 'closed'], true)) { + throw ValidationException::withMessages([ + 'bed_id' => 'This bed is not available.', + ]); + } + + $bed->update(['status' => 'occupied']); + } + + protected function assertCompatible(Visit $visit, CareUnit $unit, ?Bed $bed): void + { + if ((int) $visit->organization_id !== (int) $unit->organization_id) { + throw ValidationException::withMessages([ + 'care_unit_id' => 'Care unit belongs to a different organization.', + ]); + } + + if (! $unit->is_active) { + throw ValidationException::withMessages([ + 'care_unit_id' => 'Care unit is inactive.', + ]); + } + + if ($unit->kind === 'float_pool') { + throw ValidationException::withMessages([ + 'care_unit_id' => 'Patients cannot be placed on a float pool. Float pools are for staff only.', + ]); + } + + if (in_array($visit->status, [Visit::STATUS_COMPLETED], true)) { + throw ValidationException::withMessages([ + 'visit_id' => 'Cannot place a completed visit.', + ]); + } + + if ($bed) { + if ((int) $bed->care_unit_id !== (int) $unit->id) { + throw ValidationException::withMessages([ + 'bed_id' => 'Bed does not belong to the selected care unit.', + ]); + } + + if (! $unit->supportsBeds()) { + throw ValidationException::withMessages([ + 'bed_id' => 'This care unit kind does not use beds.', + ]); + } + + if (! $bed->is_active) { + throw ValidationException::withMessages([ + 'bed_id' => 'Bed is inactive.', + ]); + } + } elseif ($unit->supportsBeds() === false) { + // outpatient / service — bed optional, OK + } + } +} diff --git a/app/Services/Care/VisitService.php b/app/Services/Care/VisitService.php index 9bd7925..36e7f0a 100644 --- a/app/Services/Care/VisitService.php +++ b/app/Services/Care/VisitService.php @@ -42,6 +42,12 @@ class VisitService public function complete(Visit $visit, string $ownerRef, ?string $actorRef = null): Visit { + $visit->refresh(); + + app(VisitPlacementService::class)->dischargePlacement($visit, $ownerRef, $actorRef, 'Visit completed'); + + $visit->refresh(); + $visit->update([ 'status' => Visit::STATUS_COMPLETED, 'completed_at' => now(), diff --git a/database/migrations/2026_07_20_120000_add_visit_placement_and_bed_stays.php b/database/migrations/2026_07_20_120000_add_visit_placement_and_bed_stays.php new file mode 100644 index 0000000..8217c3a --- /dev/null +++ b/database/migrations/2026_07_20_120000_add_visit_placement_and_bed_stays.php @@ -0,0 +1,58 @@ +foreignId('care_unit_id')->nullable()->after('patient_id') + ->constrained('care_care_units')->nullOnDelete(); + $table->foreignId('bed_id')->nullable()->after('care_unit_id') + ->constrained('care_beds')->nullOnDelete(); + $table->timestamp('placed_at')->nullable()->after('bed_id'); + $table->string('placed_by')->nullable()->after('placed_at'); + $table->index(['care_unit_id', 'status']); + $table->index(['bed_id']); + }); + + Schema::create('care_bed_stays', 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('care_unit_id')->constrained('care_care_units')->cascadeOnDelete(); + $table->foreignId('bed_id')->nullable()->constrained('care_beds')->nullOnDelete(); + /** active | ended */ + $table->string('status')->default('active'); + $table->timestamp('admitted_at'); + $table->timestamp('discharged_at')->nullable(); + $table->string('admitted_by')->nullable(); + $table->string('discharged_by')->nullable(); + $table->string('reason')->nullable(); // admit | transfer | discharge + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['care_unit_id', 'status']); + $table->index(['bed_id', 'status']); + $table->index(['visit_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_bed_stays'); + + Schema::table('care_visits', function (Blueprint $table) { + $table->dropConstrainedForeignId('bed_id'); + $table->dropConstrainedForeignId('care_unit_id'); + $table->dropColumn(['placed_at', 'placed_by']); + }); + } +}; diff --git a/resources/views/care/admin/care-units/show.blade.php b/resources/views/care/admin/care-units/show.blade.php index f0ec8a5..6ca01a9 100644 --- a/resources/views/care/admin/care-units/show.blade.php +++ b/resources/views/care/admin/care-units/show.blade.php @@ -18,11 +18,106 @@

- Edit - Assign staff + @php + $member = auth()->user() + ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()) + : null; + $permissions = app(\App\Services\Care\CarePermissions::class); + $canManageUnit = $permissions->can($member, 'admin.departments.manage'); + @endphp + @if ($canManageUnit) + Edit unit + Assign staff + @endif
+ {{-- Current patients --}} +
+
+
+

Patients on this unit

+

Active visit placements — foundation for MAR and ward boards.

+
+

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

+
+ +
+ + + + + + + + + + + @forelse ($placedVisits as $visit) + + + + + + + @empty + + @endforelse + +
PatientBedPlaced
+ {{ $visit->patient?->fullName() ?? '—' }} + @if ($visit->patient?->patient_number) + {{ $visit->patient->patient_number }} + @endif + {{ $visit->bed?->label ?? '—' }}{{ $visit->placed_at?->format('d M Y H:i') ?? '—' }} + @if ($canPlace) +
+ @csrf @method('DELETE') + +
+ @endif +
No patients placed on this unit.
+
+ + @if ($canPlace && $unit->kind !== 'float_pool') +
+ @csrf +
+ + + @error('visit_id')

{{ $message }}

@enderror + @error('care_unit_id')

{{ $message }}

@enderror + @error('bed_id')

{{ $message }}

@enderror +
+ @if ($unit->supportsBeds()) +
+ + +
+ @else +
+ @endif +
+ +
+
+ @endif +
+ @if ($unit->supportsBeds())
@@ -32,17 +127,19 @@
-
- @csrf - - - - -
+ @if ($canManageUnit ?? false) +
+ @csrf + + + + +
+ @endif
@@ -51,24 +148,31 @@ + @forelse ($unit->beds as $bed) + @php + $occupant = $placedVisits->firstWhere('bed_id', $bed->id); + @endphp + @empty - + @endforelse
Label Room StatusOccupant
{{ $bed->label }} {{ $bed->room ?: '—' }} {{ $bedStatuses[$bed->status] ?? $bed->status }}{{ $occupant?->patient?->fullName() ?? '—' }} -
- @csrf @method('DELETE') - -
+ @if ($canManageUnit ?? false) +
+ @csrf @method('DELETE') + +
+ @endif
No beds yet.
No beds yet.
@@ -77,6 +181,9 @@ @else
This unit kind does not use beds. Outpatient clinics, service units, and float pools assign staff without bed inventory. + @if ($unit->kind === 'float_pool') + Patients cannot be placed on a float pool. + @endif
@endif
diff --git a/resources/views/care/patients/show.blade.php b/resources/views/care/patients/show.blade.php index 31f9b2c..24400d5 100644 --- a/resources/views/care/patients/show.blade.php +++ b/resources/views/care/patients/show.blade.php @@ -93,6 +93,9 @@ {{ $visit->checked_in_at?->format('d M Y H:i') ?? '—' }} · {{ config('care.visit_statuses')[$visit->status] ?? $visit->status }} @if ($visit->branch) · {{ $visit->branch->name }} @endif + @if ($visit->careUnit) + · {{ $visit->careUnit->name }}@if ($visit->bed) / {{ $visit->bed->label }}@endif + @endif

@empty

No visits yet

diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 1a0350e..a108a14 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -114,6 +114,8 @@ if ($permissions->can($member, 'admin.departments.view')) { $adminNav[] = ['name' => 'Departments', 'route' => route('care.departments.index'), 'active' => request()->routeIs('care.departments.*'), 'icon' => '']; + } + if ($permissions->can($member, 'admin.departments.view') || $permissions->can($member, 'inpatient.placement.view')) { $adminNav[] = ['name' => 'Care units', 'route' => route('care.care-units.index'), 'active' => request()->routeIs('care.care-units.*'), 'icon' => '']; } diff --git a/routes/web.php b/routes/web.php index ffd0493..2e5a975 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Care\DepartmentController; 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\DeviceController; use App\Http\Controllers\Care\DisplayPublicController; use App\Http\Controllers\Care\DisplayScreenController; @@ -428,6 +429,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/care-units/{careUnit}/beds', [BedController::class, 'store'])->name('care.care-units.beds.store'); Route::put('/care-units/{careUnit}/beds/{bed}', [BedController::class, 'update'])->name('care.care-units.beds.update'); Route::delete('/care-units/{careUnit}/beds/{bed}', [BedController::class, 'destroy'])->name('care.care-units.beds.destroy'); + Route::post('/care-units/{careUnit}/placements', [VisitPlacementController::class, 'store'])->name('care.care-units.placements.store'); + 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('/staff-assignments', [StaffAssignmentController::class, 'index'])->name('care.staff-assignments.index'); Route::get('/staff-assignments/create', [StaffAssignmentController::class, 'create'])->name('care.staff-assignments.create'); diff --git a/tests/Feature/CareVisitPlacementTest.php b/tests/Feature/CareVisitPlacementTest.php new file mode 100644 index 0000000..074e1b4 --- /dev/null +++ b/tests/Feature/CareVisitPlacementTest.php @@ -0,0 +1,281 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'placement-owner', + 'name' => 'Owner', + 'email' => 'placement-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Placement Hospital', + 'slug' => 'placement-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, + ]); + + $this->department = Department::create([ + 'owner_ref' => $this->owner->public_id, + 'branch_id' => $this->branch->id, + 'name' => "Women's Health", + 'type' => 'womens_health', + 'is_active' => true, + ]); + + $this->unit = CareUnit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'department_id' => $this->department->id, + 'name' => 'Postnatal Ward', + 'kind' => 'inpatient', + 'is_active' => true, + ]); + + $this->bed = Bed::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'care_unit_id' => $this->unit->id, + 'label' => 'PN-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-PLACE-1', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + 'gender' => 'female', + ]); + + $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_OPEN, + 'checked_in_at' => now(), + ]); + } + + public function test_place_visit_occupies_bed(): void + { + $this->actingAs($this->owner) + ->post(route('care.care-units.placements.store', $this->unit), [ + 'visit_id' => $this->visit->id, + 'bed_id' => $this->bed->id, + ]) + ->assertRedirect(route('care.care-units.show', $this->unit)); + + $this->visit->refresh(); + $this->bed->refresh(); + + $this->assertSame($this->unit->id, $this->visit->care_unit_id); + $this->assertSame($this->bed->id, $this->visit->bed_id); + $this->assertSame('occupied', $this->bed->status); + $this->assertDatabaseHas('care_bed_stays', [ + 'visit_id' => $this->visit->id, + 'bed_id' => $this->bed->id, + 'status' => BedStay::STATUS_ACTIVE, + 'reason' => 'admit', + ]); + } + + public function test_transfer_frees_old_bed_and_occupies_new(): void + { + $otherUnit = CareUnit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'department_id' => $this->department->id, + 'name' => 'Labour Ward', + 'kind' => 'inpatient', + 'is_active' => true, + ]); + $otherBed = Bed::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'care_unit_id' => $otherUnit->id, + 'label' => 'LW-02', + 'status' => 'available', + 'is_active' => true, + ]); + + $this->actingAs($this->owner) + ->post(route('care.care-units.placements.store', $this->unit), [ + 'visit_id' => $this->visit->id, + 'bed_id' => $this->bed->id, + ]) + ->assertRedirect(); + + $this->actingAs($this->owner) + ->post(route('care.visits.placement.transfer', $this->visit), [ + 'care_unit_id' => $otherUnit->id, + 'bed_id' => $otherBed->id, + ]) + ->assertRedirect(route('care.care-units.show', $otherUnit)); + + $this->visit->refresh(); + $this->bed->refresh(); + $otherBed->refresh(); + + $this->assertSame($otherUnit->id, $this->visit->care_unit_id); + $this->assertSame($otherBed->id, $this->visit->bed_id); + $this->assertSame('available', $this->bed->status); + $this->assertSame('occupied', $otherBed->status); + $this->assertSame(1, BedStay::query()->where('visit_id', $this->visit->id)->active()->count()); + } + + public function test_discharge_placement_frees_bed(): void + { + $this->actingAs($this->owner) + ->post(route('care.care-units.placements.store', $this->unit), [ + 'visit_id' => $this->visit->id, + 'bed_id' => $this->bed->id, + ]); + + $this->actingAs($this->owner) + ->delete(route('care.visits.placement.destroy', $this->visit)) + ->assertRedirect(); + + $this->visit->refresh(); + $this->bed->refresh(); + + $this->assertNull($this->visit->care_unit_id); + $this->assertNull($this->visit->bed_id); + $this->assertSame('available', $this->bed->status); + } + + public function test_completing_visit_discharges_placement(): void + { + $this->actingAs($this->owner) + ->post(route('care.care-units.placements.store', $this->unit), [ + 'visit_id' => $this->visit->id, + 'bed_id' => $this->bed->id, + ]); + + app(VisitService::class)->complete($this->visit, $this->owner->public_id, $this->owner->public_id); + + $this->visit->refresh(); + $this->bed->refresh(); + + $this->assertSame(Visit::STATUS_COMPLETED, $this->visit->status); + $this->assertNull($this->visit->care_unit_id); + $this->assertSame('available', $this->bed->status); + } + + public function test_cannot_place_on_float_pool(): void + { + $float = CareUnit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'department_id' => $this->department->id, + 'name' => 'Float Pool', + 'kind' => 'float_pool', + 'is_active' => true, + ]); + + $this->actingAs($this->owner) + ->from(route('care.care-units.show', $float)) + ->post(route('care.care-units.placements.store', $float), [ + 'visit_id' => $this->visit->id, + ]) + ->assertRedirect() + ->assertSessionHasErrors('care_unit_id'); + } + + public function test_second_patient_cannot_take_occupied_bed(): void + { + $this->actingAs($this->owner) + ->post(route('care.care-units.placements.store', $this->unit), [ + 'visit_id' => $this->visit->id, + 'bed_id' => $this->bed->id, + ]); + + $otherPatient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-PLACE-2', + 'first_name' => 'Kwame', + 'last_name' => 'Boateng', + 'gender' => 'male', + ]); + $otherVisit = Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $otherPatient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $this->actingAs($this->owner) + ->from(route('care.care-units.show', $this->unit)) + ->post(route('care.care-units.placements.store', $this->unit), [ + 'visit_id' => $otherVisit->id, + 'bed_id' => $this->bed->id, + ]) + ->assertRedirect() + ->assertSessionHasErrors('bed_id'); + } +}