From 45a1a9514212ca5bdbdfcff43d2745cecf8c8d09 Mon Sep 17 00:00:00 2001
From: isaacclad
Date: Mon, 20 Jul 2026 10:10:37 +0000
Subject: [PATCH] Add visit placement on care units and beds.
Patients can be admitted, transferred, and discharged from unit/bed stays with occupancy sync, so MAR and ward boards have inpatient context.
Co-authored-by: Cursor
---
.../Controllers/Care/CareUnitController.php | 51 +++-
.../Care/VisitPlacementController.php | 118 ++++++++
app/Models/Bed.php | 16 +
app/Models/BedStay.php | 67 +++++
app/Models/CareUnit.php | 10 +
app/Models/Visit.php | 26 ++
app/Services/Care/CarePermissions.php | 26 +-
app/Services/Care/PatientService.php | 2 +-
app/Services/Care/VisitPlacementService.php | 245 +++++++++++++++
app/Services/Care/VisitService.php | 6 +
...0000_add_visit_placement_and_bed_stays.php | 58 ++++
.../care/admin/care-units/show.blade.php | 143 +++++++--
resources/views/care/patients/show.blade.php | 3 +
resources/views/partials/sidebar.blade.php | 2 +
routes/web.php | 4 +
tests/Feature/CareVisitPlacementTest.php | 281 ++++++++++++++++++
16 files changed, 1036 insertions(+), 22 deletions(-)
create mode 100644 app/Http/Controllers/Care/VisitPlacementController.php
create mode 100644 app/Models/BedStay.php
create mode 100644 app/Services/Care/VisitPlacementService.php
create mode 100644 database/migrations/2026_07_20_120000_add_visit_placement_and_bed_stays.php
create mode 100644 tests/Feature/CareVisitPlacementTest.php
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
+
+
+
+
+
+
+ | Patient |
+ Bed |
+ Placed |
+ |
+
+
+
+ @forelse ($placedVisits as $visit)
+
+ |
+ {{ $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)
+
+ @endif
+ |
+
+ @empty
+ | No patients placed on this unit. |
+ @endforelse
+
+
+
+
+ @if ($canPlace && $unit->kind !== 'float_pool')
+
+ @endif
+
+
@if ($unit->supportsBeds())
-
+ @if ($canManageUnit ?? false)
+
+ @endif
@@ -51,24 +148,31 @@
| Label |
Room |
Status |
+ Occupant |
|
@forelse ($unit->beds as $bed)
+ @php
+ $occupant = $placedVisits->firstWhere('bed_id', $bed->id);
+ @endphp
| {{ $bed->label }} |
{{ $bed->room ?: '—' }} |
{{ $bedStatuses[$bed->status] ?? $bed->status }} |
+ {{ $occupant?->patient?->fullName() ?? '—' }} |
-
+ @if ($canManageUnit ?? false)
+
+ @endif
|
@empty
- | No beds yet. |
+ | No beds yet. |
@endforelse
@@ -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');
+ }
+}