diff --git a/app/Http/Controllers/Care/BedController.php b/app/Http/Controllers/Care/BedController.php new file mode 100644 index 0000000..4dcaf20 --- /dev/null +++ b/app/Http/Controllers/Care/BedController.php @@ -0,0 +1,105 @@ +authorizeAbility($request, 'admin.departments.manage'); + $this->authorizeOwner($request, $careUnit); + abort_unless($careUnit->supportsBeds(), 422, 'Beds are only for inpatient care units.'); + + $validated = $request->validate([ + 'label' => ['required', 'string', 'max:100'], + 'room' => ['nullable', 'string', 'max:100'], + 'status' => ['required', 'string', Rule::in(array_keys(config('care.bed_statuses')))], + ]); + + $bed = Bed::create([ + 'owner_ref' => $this->ownerRef($request), + 'organization_id' => $careUnit->organization_id, + 'care_unit_id' => $careUnit->id, + 'label' => $validated['label'], + 'room' => $validated['room'] ?? null, + 'status' => $validated['status'], + 'is_active' => true, + ]); + + AuditLogger::record( + $this->ownerRef($request), + 'bed.created', + $careUnit->organization_id, + $this->ownerRef($request), + Bed::class, + $bed->id, + ); + + return redirect()->route('care.care-units.show', $careUnit)->with('success', 'Bed added.'); + } + + public function update(Request $request, CareUnit $careUnit, Bed $bed): RedirectResponse + { + $this->authorizeAbility($request, 'admin.departments.manage'); + $this->authorizeOwner($request, $careUnit); + abort_unless((int) $bed->care_unit_id === (int) $careUnit->id, 404); + $this->authorizeOwner($request, $bed); + + $validated = $request->validate([ + 'label' => ['required', 'string', 'max:100'], + 'room' => ['nullable', 'string', 'max:100'], + 'status' => ['required', 'string', Rule::in(array_keys(config('care.bed_statuses')))], + 'is_active' => ['boolean'], + ]); + + $bed->update([ + 'label' => $validated['label'], + 'room' => $validated['room'] ?? null, + 'status' => $validated['status'], + 'is_active' => $request->boolean('is_active', true), + ]); + + AuditLogger::record( + $this->ownerRef($request), + 'bed.updated', + $careUnit->organization_id, + $this->ownerRef($request), + Bed::class, + $bed->id, + ); + + return redirect()->route('care.care-units.show', $careUnit)->with('success', 'Bed updated.'); + } + + public function destroy(Request $request, CareUnit $careUnit, Bed $bed): RedirectResponse + { + $this->authorizeAbility($request, 'admin.departments.manage'); + $this->authorizeOwner($request, $careUnit); + abort_unless((int) $bed->care_unit_id === (int) $careUnit->id, 404); + $this->authorizeOwner($request, $bed); + + $bed->delete(); + + AuditLogger::record( + $this->ownerRef($request), + 'bed.deleted', + $careUnit->organization_id, + $this->ownerRef($request), + Bed::class, + $bed->id, + ); + + return redirect()->route('care.care-units.show', $careUnit)->with('success', 'Bed removed.'); + } +} diff --git a/app/Http/Controllers/Care/CareUnitController.php b/app/Http/Controllers/Care/CareUnitController.php new file mode 100644 index 0000000..19c3660 --- /dev/null +++ b/app/Http/Controllers/Care/CareUnitController.php @@ -0,0 +1,182 @@ +authorizeAbility($request, 'admin.departments.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $units = CareUnit::owned($owner) + ->where('organization_id', $organization->id) + ->with(['department.branch']) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + $heroStats = [ + 'total' => $units->count(), + 'inpatient' => $units->where('kind', 'inpatient')->count(), + 'float' => $units->where('kind', 'float_pool')->count(), + 'active' => $units->where('is_active', true)->count(), + ]; + + return view('care.admin.care-units.index', [ + 'units' => $units, + 'organization' => $organization, + 'kinds' => config('care.care_unit_kinds'), + 'heroStats' => $heroStats, + ]); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'admin.departments.manage'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $departments = Department::owned($owner) + ->whereHas('branch', fn ($q) => $q->where('organization_id', $organization->id)) + ->with('branch') + ->where('is_active', true) + ->orderBy('name') + ->get(); + + return view('care.admin.care-units.create', [ + 'organization' => $organization, + 'departments' => $departments, + 'kinds' => config('care.care_unit_kinds'), + 'selectedDepartmentId' => $request->integer('department_id') ?: null, + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'admin.departments.manage'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $validated = $request->validate([ + 'department_id' => ['required', 'integer', 'exists:care_departments,id'], + 'name' => ['required', 'string', 'max:255'], + 'code' => ['nullable', 'string', 'max:50'], + 'kind' => ['required', 'string', Rule::in(array_keys(config('care.care_unit_kinds')))], + ]); + + $department = Department::owned($owner)->with('branch')->findOrFail($validated['department_id']); + abort_unless((int) $department->branch?->organization_id === (int) $organization->id, 404); + + $unit = CareUnit::create([ + 'owner_ref' => $owner, + 'organization_id' => $organization->id, + 'department_id' => $department->id, + 'name' => $validated['name'], + 'code' => $validated['code'] ?? null, + 'kind' => $validated['kind'], + 'is_active' => true, + ]); + + AuditLogger::record($owner, 'care_unit.created', $organization->id, $owner, CareUnit::class, $unit->id); + + return redirect()->route('care.care-units.show', $unit)->with('success', 'Care unit created.'); + } + + public function show(Request $request, CareUnit $careUnit): View + { + $this->authorizeAbility($request, 'admin.departments.view'); + $this->authorizeOwner($request, $careUnit); + + $careUnit->load(['department.branch', 'beds']); + + return view('care.admin.care-units.show', [ + 'unit' => $careUnit, + 'kinds' => config('care.care_unit_kinds'), + 'bedStatuses' => config('care.bed_statuses'), + ]); + } + + public function edit(Request $request, CareUnit $careUnit): View + { + $this->authorizeAbility($request, 'admin.departments.manage'); + $this->authorizeOwner($request, $careUnit); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $departments = Department::owned($owner) + ->whereHas('branch', fn ($q) => $q->where('organization_id', $organization->id)) + ->with('branch') + ->orderBy('name') + ->get(); + + return view('care.admin.care-units.edit', [ + 'unit' => $careUnit, + 'departments' => $departments, + 'kinds' => config('care.care_unit_kinds'), + ]); + } + + public function update(Request $request, CareUnit $careUnit): RedirectResponse + { + $this->authorizeAbility($request, 'admin.departments.manage'); + $this->authorizeOwner($request, $careUnit); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $validated = $request->validate([ + 'department_id' => ['required', 'integer', 'exists:care_departments,id'], + 'name' => ['required', 'string', 'max:255'], + 'code' => ['nullable', 'string', 'max:50'], + 'kind' => ['required', 'string', Rule::in(array_keys(config('care.care_unit_kinds')))], + 'is_active' => ['boolean'], + ]); + + $department = Department::owned($owner)->with('branch')->findOrFail($validated['department_id']); + abort_unless((int) $department->branch?->organization_id === (int) $organization->id, 404); + + $careUnit->update([ + 'department_id' => $department->id, + 'name' => $validated['name'], + 'code' => $validated['code'] ?? null, + 'kind' => $validated['kind'], + 'is_active' => $request->boolean('is_active', true), + ]); + + AuditLogger::record($owner, 'care_unit.updated', $organization->id, $owner, CareUnit::class, $careUnit->id); + + return redirect()->route('care.care-units.show', $careUnit)->with('success', 'Care unit updated.'); + } + + public function destroy(Request $request, CareUnit $careUnit): RedirectResponse + { + $this->authorizeAbility($request, 'admin.departments.manage'); + $this->authorizeOwner($request, $careUnit); + + $careUnit->delete(); + + AuditLogger::record( + $this->ownerRef($request), + 'care_unit.deleted', + $careUnit->organization_id, + $this->ownerRef($request), + CareUnit::class, + $careUnit->id, + ); + + return redirect()->route('care.care-units.index')->with('success', 'Care unit removed.'); + } +} diff --git a/app/Http/Controllers/Care/StaffAssignmentController.php b/app/Http/Controllers/Care/StaffAssignmentController.php new file mode 100644 index 0000000..d87ecd5 --- /dev/null +++ b/app/Http/Controllers/Care/StaffAssignmentController.php @@ -0,0 +1,254 @@ +authorizeAbility($request, 'admin.members.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $assignments = StaffAssignment::owned($owner) + ->where('organization_id', $organization->id) + ->with(['member.branch', 'department.branch', 'careUnit']) + ->orderByDesc('starts_on') + ->orderByDesc('id') + ->get(); + + $heroStats = [ + 'total' => $assignments->count(), + 'active' => $assignments->where('status', 'active')->filter(fn (StaffAssignment $a) => $a->isEffectiveOn())->count(), + 'temporary' => $assignments->where('kind', 'temporary')->count(), + 'primary' => $assignments->where('kind', 'primary')->count(), + ]; + + return view('care.admin.staff-assignments.index', [ + 'assignments' => $assignments, + 'organization' => $organization, + 'kinds' => config('care.staff_assignment_kinds'), + 'statuses' => config('care.staff_assignment_statuses'), + 'assignmentRoles' => config('care.staff_assignment_roles'), + 'shiftCodes' => config('care.staff_shift_codes'), + 'memberLabels' => $this->memberLabels($assignments->pluck('member')->filter()), + 'heroStats' => $heroStats, + ]); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'admin.members.manage'); + + return view('care.admin.staff-assignments.create', $this->formData($request)); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'admin.members.manage'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $validated = $this->validated($request, $organization->id, $owner); + + $assignment = StaffAssignment::create([ + 'owner_ref' => $owner, + 'organization_id' => $organization->id, + ...$validated, + ]); + + AuditLogger::record($owner, 'staff_assignment.created', $organization->id, $owner, StaffAssignment::class, $assignment->id); + + return redirect()->route('care.staff-assignments.index')->with('success', 'Staff assignment created.'); + } + + public function edit(Request $request, StaffAssignment $staffAssignment): View + { + $this->authorizeAbility($request, 'admin.members.manage'); + $this->authorizeOwner($request, $staffAssignment); + + return view('care.admin.staff-assignments.edit', [ + ...$this->formData($request), + 'assignment' => $staffAssignment, + ]); + } + + public function update(Request $request, StaffAssignment $staffAssignment): RedirectResponse + { + $this->authorizeAbility($request, 'admin.members.manage'); + $this->authorizeOwner($request, $staffAssignment); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $validated = $this->validated($request, $organization->id, $owner); + $staffAssignment->update($validated); + + AuditLogger::record($owner, 'staff_assignment.updated', $organization->id, $owner, StaffAssignment::class, $staffAssignment->id); + + return redirect()->route('care.staff-assignments.index')->with('success', 'Staff assignment updated.'); + } + + public function destroy(Request $request, StaffAssignment $staffAssignment): RedirectResponse + { + $this->authorizeAbility($request, 'admin.members.manage'); + $this->authorizeOwner($request, $staffAssignment); + + $staffAssignment->delete(); + + AuditLogger::record( + $this->ownerRef($request), + 'staff_assignment.deleted', + $staffAssignment->organization_id, + $this->ownerRef($request), + StaffAssignment::class, + $staffAssignment->id, + ); + + return redirect()->route('care.staff-assignments.index')->with('success', 'Staff assignment removed.'); + } + + /** + * @return array + */ + protected function formData(Request $request): array + { + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $members = Member::owned($owner) + ->where('organization_id', $organization->id) + ->with('branch') + ->orderBy('role') + ->get(); + + $departments = Department::owned($owner) + ->whereHas('branch', fn ($q) => $q->where('organization_id', $organization->id)) + ->with('branch') + ->where('is_active', true) + ->orderBy('name') + ->get(); + + $units = CareUnit::owned($owner) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->with('department') + ->orderBy('name') + ->get(); + + return [ + 'organization' => $organization, + 'members' => $members, + 'memberLabels' => $this->memberLabels($members), + 'departments' => $departments, + 'units' => $units, + 'kinds' => config('care.staff_assignment_kinds'), + 'statuses' => config('care.staff_assignment_statuses'), + 'assignmentRoles' => config('care.staff_assignment_roles'), + 'shiftCodes' => config('care.staff_shift_codes'), + 'rbacRoles' => config('care.roles'), + 'selectedMemberId' => $request->integer('member_id') ?: null, + ]; + } + + /** + * @return array + */ + protected function validated(Request $request, int $organizationId, string $owner): array + { + $validated = $request->validate([ + 'member_id' => ['required', 'integer', 'exists:care_members,id'], + 'department_id' => ['nullable', 'integer', 'exists:care_departments,id'], + 'care_unit_id' => [ + 'nullable', + 'integer', + 'exists:care_care_units,id', + Rule::requiredIf(fn () => $request->input('kind') === 'temporary'), + ], + 'kind' => ['required', 'string', Rule::in(array_keys(config('care.staff_assignment_kinds')))], + 'assignment_role' => ['nullable', 'string', Rule::in(array_keys(config('care.staff_assignment_roles')))], + 'shift_code' => ['nullable', 'string', Rule::in(array_keys(config('care.staff_shift_codes')))], + 'starts_on' => ['required', 'date'], + 'ends_on' => ['nullable', 'date', 'after_or_equal:starts_on'], + 'status' => ['required', 'string', Rule::in(array_keys(config('care.staff_assignment_statuses')))], + 'notes' => ['nullable', 'string', 'max:2000'], + ], [ + 'care_unit_id.required' => 'Temporary assignments require a care unit.', + ]); + + $member = Member::owned($owner)->findOrFail($validated['member_id']); + abort_unless((int) $member->organization_id === $organizationId, 404); + + if (! empty($validated['department_id'])) { + $department = Department::owned($owner)->with('branch')->findOrFail($validated['department_id']); + abort_unless((int) $department->branch?->organization_id === $organizationId, 404); + } else { + $validated['department_id'] = null; + } + + if (! empty($validated['care_unit_id'])) { + $unit = CareUnit::owned($owner)->findOrFail($validated['care_unit_id']); + abort_unless((int) $unit->organization_id === $organizationId, 404); + if (! empty($validated['department_id']) && (int) $unit->department_id !== (int) $validated['department_id']) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'care_unit_id' => 'Care unit must belong to the selected department.', + ]); + } + if (empty($validated['department_id'])) { + $validated['department_id'] = $unit->department_id; + } + } else { + $validated['care_unit_id'] = null; + } + + return [ + 'member_id' => (int) $validated['member_id'], + 'department_id' => $validated['department_id'] ? (int) $validated['department_id'] : null, + 'care_unit_id' => $validated['care_unit_id'] ? (int) $validated['care_unit_id'] : null, + 'kind' => $validated['kind'], + 'assignment_role' => $validated['assignment_role'] ?? null, + 'shift_code' => $validated['shift_code'] ?? null, + 'starts_on' => $validated['starts_on'], + 'ends_on' => $validated['ends_on'] ?? null, + 'status' => $validated['status'], + 'notes' => $validated['notes'] ?? null, + ]; + } + + /** + * @param iterable $members + * @return array + */ + protected function memberLabels(iterable $members): array + { + $list = collect($members)->filter()->unique('id')->values(); + $refs = $list->pluck('user_ref')->all(); + $emails = User::query()->whereIn('public_id', $refs)->pluck('email', 'public_id'); + $roles = config('care.roles'); + + $labels = []; + foreach ($list as $member) { + $display = str_contains((string) $member->user_ref, '@') + ? $member->user_ref + : ($emails[$member->user_ref] ?? $member->user_ref); + $roleLabel = $roles[$member->role] ?? $member->role; + $labels[(int) $member->id] = $display.' · '.$roleLabel; + } + + return $labels; + } +} diff --git a/app/Models/Bed.php b/app/Models/Bed.php new file mode 100644 index 0000000..9b428d0 --- /dev/null +++ b/app/Models/Bed.php @@ -0,0 +1,44 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function careUnit(): BelongsTo + { + return $this->belongsTo(CareUnit::class, 'care_unit_id'); + } +} diff --git a/app/Models/CareUnit.php b/app/Models/CareUnit.php new file mode 100644 index 0000000..19dc0ee --- /dev/null +++ b/app/Models/CareUnit.php @@ -0,0 +1,76 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class, 'department_id'); + } + + public function beds(): HasMany + { + return $this->hasMany(Bed::class, 'care_unit_id')->orderBy('sort_order')->orderBy('label'); + } + + public function assignments(): HasMany + { + return $this->hasMany(StaffAssignment::class, 'care_unit_id'); + } + + public function supportsBeds(): bool + { + return $this->kind === 'inpatient'; + } + + public function labelForSelect(): string + { + $dept = $this->relationLoaded('department') + ? $this->department + : $this->department()->first(); + + $kindLabel = config('care.care_unit_kinds.'.$this->kind, $this->kind); + $base = $this->name.($kindLabel ? ' ('.$kindLabel.')' : ''); + + if ($dept?->name) { + return $dept->name.' — '.$base; + } + + return $base; + } +} diff --git a/app/Models/Department.php b/app/Models/Department.php index 123a1e5..54a26a5 100644 --- a/app/Models/Department.php +++ b/app/Models/Department.php @@ -5,6 +5,7 @@ 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\SoftDeletes; class Department extends Model @@ -27,6 +28,13 @@ class Department extends Model return $this->belongsTo(Branch::class, 'branch_id'); } + public function careUnits(): HasMany + { + return $this->hasMany(CareUnit::class, 'department_id') + ->orderBy('sort_order') + ->orderBy('name'); + } + /** * Select option label — includes branch when set so multi-branch duplicates are distinguishable. */ diff --git a/app/Models/Member.php b/app/Models/Member.php index 6f69999..7f13ca8 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -5,6 +5,7 @@ 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; class Member extends Model { @@ -24,6 +25,11 @@ class Member extends Model return $this->belongsTo(Branch::class, 'branch_id'); } + public function staffAssignments(): HasMany + { + return $this->hasMany(StaffAssignment::class, 'member_id'); + } + public function hasRole(string ...$roles): bool { return in_array($this->role, $roles, true); diff --git a/app/Models/StaffAssignment.php b/app/Models/StaffAssignment.php new file mode 100644 index 0000000..5c843e5 --- /dev/null +++ b/app/Models/StaffAssignment.php @@ -0,0 +1,95 @@ + 'date', + 'ends_on' => 'date', + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function member(): BelongsTo + { + return $this->belongsTo(Member::class, 'member_id'); + } + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class, 'department_id'); + } + + public function careUnit(): BelongsTo + { + return $this->belongsTo(CareUnit::class, 'care_unit_id'); + } + + public function scopeEffectiveOn(Builder $query, CarbonInterface|string|null $date = null): Builder + { + $day = $date + ? (\Illuminate\Support\Carbon::parse($date)->toDateString()) + : now()->toDateString(); + + return $query + ->whereDate('starts_on', '<=', $day) + ->where(function (Builder $q) use ($day) { + $q->whereNull('ends_on')->orWhereDate('ends_on', '>=', $day); + }); + } + + public function scopeActiveStatus(Builder $query): Builder + { + return $query->where('status', 'active'); + } + + public function isEffectiveOn(CarbonInterface|string|null $date = null): bool + { + $day = $date + ? \Illuminate\Support\Carbon::parse($date)->startOfDay() + : now()->startOfDay(); + + if ($this->starts_on->gt($day)) { + return false; + } + + if ($this->ends_on !== null && $this->ends_on->lt($day)) { + return false; + } + + return $this->status === 'active'; + } +} diff --git a/config/care.php b/config/care.php index f9d61b9..b2becb8 100644 --- a/config/care.php +++ b/config/care.php @@ -77,6 +77,70 @@ return [ 'fertility' => 'Fertility & Reproductive Medicine', 'child_welfare' => 'Child Welfare Clinic', 'ambulance' => 'Ambulance', + 'nursing' => 'Nursing Services', + ], + + /* + | Care units live under departments. Kind drives beds (inpatient only) + | and float-pool assignment behaviour. + */ + 'care_unit_kinds' => [ + 'inpatient' => 'Inpatient unit', + 'outpatient' => 'Outpatient / clinic', + 'service' => 'Service unit', + 'float_pool' => 'Float pool', + ], + + 'bed_statuses' => [ + 'available' => 'Available', + 'occupied' => 'Occupied', + 'reserved' => 'Reserved', + 'maintenance' => 'Maintenance', + 'closed' => 'Closed', + ], + + /* + | Employment assignment kind — not permanent nurse→ward ownership. + | primary = home unit; temporary = float/coverage day(s); specialty = specialty-wide. + */ + 'staff_assignment_kinds' => [ + 'primary' => 'Primary assignment', + 'temporary' => 'Temporary assignment', + 'specialty' => 'Specialty assignment', + ], + + 'staff_assignment_statuses' => [ + 'active' => 'Active', + 'scheduled' => 'Scheduled', + 'ended' => 'Ended', + ], + + /* + | Post / grade on an assignment (distinct from Member.role RBAC). + */ + 'staff_assignment_roles' => [ + 'staff_nurse' => 'Staff Nurse', + 'senior_staff_nurse' => 'Senior Staff Nurse', + 'clinic_nurse' => 'Clinic Nurse', + 'midwife' => 'Midwife', + 'ward_manager' => 'Ward Manager', + 'matron' => 'Matron', + 'theatre_nurse' => 'Theatre Nurse', + 'specialist_nurse' => 'Specialist Nurse', + 'float_nurse' => 'Float Nurse', + 'consultant' => 'Consultant', + 'resident' => 'Resident Doctor', + 'other' => 'Other', + ], + + /* + | Lightweight shift codes until full roster entities (Phase 3). + */ + 'staff_shift_codes' => [ + 'day' => 'Day shift', + 'evening' => 'Evening shift', + 'night' => 'Night shift', + 'rotating' => 'Rotating', ], /* diff --git a/database/migrations/2026_07_20_100000_create_care_units_beds_assignments.php b/database/migrations/2026_07_20_100000_create_care_units_beds_assignments.php new file mode 100644 index 0000000..7dde5e9 --- /dev/null +++ b/database/migrations/2026_07_20_100000_create_care_units_beds_assignments.php @@ -0,0 +1,86 @@ +id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('department_id')->constrained('care_departments')->cascadeOnDelete(); + $table->string('name'); + $table->string('code')->nullable(); + /** inpatient | outpatient | service | float_pool */ + $table->string('kind')->default('inpatient'); + $table->boolean('is_active')->default(true); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['organization_id', 'kind']); + $table->index(['department_id', 'is_active']); + }); + + Schema::create('care_beds', 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('label'); + $table->string('room')->nullable(); + /** available | occupied | reserved | maintenance | closed */ + $table->string('status')->default('available'); + $table->boolean('is_active')->default(true); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['care_unit_id', 'status']); + $table->index(['organization_id', 'is_active']); + }); + + Schema::create('care_staff_assignments', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('member_id')->constrained('care_members')->cascadeOnDelete(); + $table->foreignId('department_id')->nullable()->constrained('care_departments')->nullOnDelete(); + $table->foreignId('care_unit_id')->nullable()->constrained('care_care_units')->nullOnDelete(); + /** primary | temporary | specialty */ + $table->string('kind')->default('primary'); + /** Nursing/post title on this assignment — not RBAC Member.role */ + $table->string('assignment_role')->nullable(); + /** day | evening | night | rotating — full shift entities come later */ + $table->string('shift_code')->nullable(); + $table->date('starts_on'); + $table->date('ends_on')->nullable(); + /** active | scheduled | ended */ + $table->string('status')->default('active'); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['organization_id', 'status']); + $table->index(['member_id', 'status']); + $table->index(['care_unit_id', 'status']); + $table->index(['starts_on', 'ends_on']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_staff_assignments'); + Schema::dropIfExists('care_beds'); + Schema::dropIfExists('care_care_units'); + } +}; diff --git a/resources/views/care/admin/care-units/create.blade.php b/resources/views/care/admin/care-units/create.blade.php new file mode 100644 index 0000000..fe92f84 --- /dev/null +++ b/resources/views/care/admin/care-units/create.blade.php @@ -0,0 +1,39 @@ + +
+

Add care unit

+

Place the unit under a department. Use Float pool for nurses without a permanent ward.

+
+ @csrf +
+ + + @error('department_id')

{{ $message }}

@enderror +
+
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + +
+
+ + +
+ +
+
+
diff --git a/resources/views/care/admin/care-units/edit.blade.php b/resources/views/care/admin/care-units/edit.blade.php new file mode 100644 index 0000000..4cb7ea2 --- /dev/null +++ b/resources/views/care/admin/care-units/edit.blade.php @@ -0,0 +1,36 @@ + +
+

Edit care unit

+
+ @csrf @method('PUT') +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
diff --git a/resources/views/care/admin/care-units/index.blade.php b/resources/views/care/admin/care-units/index.blade.php new file mode 100644 index 0000000..6bff097 --- /dev/null +++ b/resources/views/care/admin/care-units/index.blade.php @@ -0,0 +1,52 @@ + +
+ + + Add care unit + + + +
+ + + + + + + + + + + + @forelse ($units as $unit) + + + + + + + + @empty + + @endforelse + +
NameKindDepartmentBranch
+ {{ $unit->name }} + @if ($unit->code) + {{ $unit->code }} + @endif + {{ $kinds[$unit->kind] ?? $unit->kind }}{{ $unit->department?->name }}{{ $unit->department?->branch?->name }} + Open +
No care units yet.
+
+
+
diff --git a/resources/views/care/admin/care-units/show.blade.php b/resources/views/care/admin/care-units/show.blade.php new file mode 100644 index 0000000..f0ec8a5 --- /dev/null +++ b/resources/views/care/admin/care-units/show.blade.php @@ -0,0 +1,83 @@ + +
+
+
+

+ Care units + / + {{ $unit->department?->name }} +

+

{{ $unit->name }}

+

+ {{ $kinds[$unit->kind] ?? $unit->kind }} + @if ($unit->code) · {{ $unit->code }} @endif + · {{ $unit->department?->branch?->name }} + @unless ($unit->is_active) + · Inactive + @endunless +

+
+ +
+ + @if ($unit->supportsBeds()) +
+
+
+

Beds

+

Rooms and bed labels for this inpatient unit.

+
+
+ +
+ @csrf + + + + +
+ +
+ + + + + + + + + + + @forelse ($unit->beds as $bed) + + + + + + + @empty + + @endforelse + +
LabelRoomStatus
{{ $bed->label }}{{ $bed->room ?: '—' }}{{ $bedStatuses[$bed->status] ?? $bed->status }} +
+ @csrf @method('DELETE') + +
+
No beds yet.
+
+
+ @else +
+ This unit kind does not use beds. Outpatient clinics, service units, and float pools assign staff without bed inventory. +
+ @endif +
+
diff --git a/resources/views/care/admin/departments/index.blade.php b/resources/views/care/admin/departments/index.blade.php index 7020889..3729cb8 100644 --- a/resources/views/care/admin/departments/index.blade.php +++ b/resources/views/care/admin/departments/index.blade.php @@ -10,6 +10,7 @@ ['value' => number_format($heroStats['branches']), 'label' => 'Branches'], ]"> + Care units Add department diff --git a/resources/views/care/admin/staff-assignments/create.blade.php b/resources/views/care/admin/staff-assignments/create.blade.php new file mode 100644 index 0000000..c952790 --- /dev/null +++ b/resources/views/care/admin/staff-assignments/create.blade.php @@ -0,0 +1,122 @@ +@php + $assignment = $assignment ?? null; +@endphp + + +
+

{{ $assignment ? 'Edit staff assignment' : 'Add staff assignment' }}

+

RBAC role stays on the team member. This record is their unit placement over a date range.

+ +
+ @csrf + @if ($assignment) @method('PUT') @endif + +
+ + + @error('member_id')

{{ $message }}

@enderror +
+ +
+ + +

Temporary requires a care unit (e.g. float nurse covering Emergency today).

+
+ +
+ + +
+ +
+ + + @error('care_unit_id')

{{ $message }}

@enderror +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ + @if ($assignment) +
+ @csrf @method('DELETE') + +
+ @endif +
+
diff --git a/resources/views/care/admin/staff-assignments/edit.blade.php b/resources/views/care/admin/staff-assignments/edit.blade.php new file mode 100644 index 0000000..43a05db --- /dev/null +++ b/resources/views/care/admin/staff-assignments/edit.blade.php @@ -0,0 +1 @@ +@include('care.admin.staff-assignments.create') diff --git a/resources/views/care/admin/staff-assignments/index.blade.php b/resources/views/care/admin/staff-assignments/index.blade.php new file mode 100644 index 0000000..e0e87c4 --- /dev/null +++ b/resources/views/care/admin/staff-assignments/index.blade.php @@ -0,0 +1,67 @@ + +
+ + + Add assignment + + + +
+ + + + + + + + + + + + + + + @forelse ($assignments as $assignment) + + + + + + + + + + + @empty + + @endforelse + +
StaffKindPostUnit / DeptShiftDatesStatus
{{ $memberLabels[$assignment->member_id] ?? ('#'.$assignment->member_id) }}{{ $kinds[$assignment->kind] ?? $assignment->kind }}{{ $assignmentRoles[$assignment->assignment_role] ?? ($assignment->assignment_role ?: '—') }} + {{ $assignment->careUnit?->name ?? '—' }} + @if ($assignment->department) +
{{ $assignment->department->name }}
+ @endif +
{{ $shiftCodes[$assignment->shift_code] ?? ($assignment->shift_code ?: '—') }} + {{ $assignment->starts_on->format('Y-m-d') }} + → + {{ $assignment->ends_on?->format('Y-m-d') ?? 'open' }} + + {{ $statuses[$assignment->status] ?? $assignment->status }} + @if ($assignment->isEffectiveOn()) + today + @endif + + Edit +
No staff assignments yet.
+
+
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index b3361fb..1a0350e 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -114,6 +114,12 @@ if ($permissions->can($member, 'admin.departments.view')) { $adminNav[] = ['name' => 'Departments', 'route' => route('care.departments.index'), 'active' => request()->routeIs('care.departments.*'), 'icon' => '']; + $adminNav[] = ['name' => 'Care units', 'route' => route('care.care-units.index'), 'active' => request()->routeIs('care.care-units.*'), + 'icon' => '']; + } + if ($permissions->can($member, 'admin.members.view')) { + $adminNav[] = ['name' => 'Staff assignments', 'route' => route('care.staff-assignments.index'), 'active' => request()->routeIs('care.staff-assignments.*'), + 'icon' => '']; } if ($permissions->can($member, 'admin.practitioners.view')) { $adminNav[] = ['name' => 'Practitioners', 'route' => route('care.practitioners.index'), 'active' => request()->routeIs('care.practitioners.*'), diff --git a/routes/web.php b/routes/web.php index 4b52d58..ffd0493 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,9 @@ use App\Http\Controllers\Care\BranchController; use App\Http\Controllers\Care\ConsultationController; use App\Http\Controllers\Care\DashboardController; 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\DeviceController; use App\Http\Controllers\Care\DisplayPublicController; use App\Http\Controllers\Care\DisplayScreenController; @@ -415,6 +418,24 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::put('/departments/{department}', [DepartmentController::class, 'update'])->name('care.departments.update'); Route::delete('/departments/{department}', [DepartmentController::class, 'destroy'])->name('care.departments.destroy'); + Route::get('/care-units', [CareUnitController::class, 'index'])->name('care.care-units.index'); + Route::get('/care-units/create', [CareUnitController::class, 'create'])->name('care.care-units.create'); + Route::post('/care-units', [CareUnitController::class, 'store'])->name('care.care-units.store'); + Route::get('/care-units/{careUnit}', [CareUnitController::class, 'show'])->name('care.care-units.show'); + Route::get('/care-units/{careUnit}/edit', [CareUnitController::class, 'edit'])->name('care.care-units.edit'); + Route::put('/care-units/{careUnit}', [CareUnitController::class, 'update'])->name('care.care-units.update'); + Route::delete('/care-units/{careUnit}', [CareUnitController::class, 'destroy'])->name('care.care-units.destroy'); + 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::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'); + Route::get('/staff-assignments/{staffAssignment}/edit', [StaffAssignmentController::class, 'edit'])->name('care.staff-assignments.edit'); + Route::put('/staff-assignments/{staffAssignment}', [StaffAssignmentController::class, 'update'])->name('care.staff-assignments.update'); + Route::delete('/staff-assignments/{staffAssignment}', [StaffAssignmentController::class, 'destroy'])->name('care.staff-assignments.destroy'); + Route::get('/practitioners', [PractitionerController::class, 'index'])->name('care.practitioners.index'); Route::get('/practitioners/create', [PractitionerController::class, 'create'])->name('care.practitioners.create'); Route::post('/practitioners', [PractitionerController::class, 'store'])->name('care.practitioners.store'); diff --git a/tests/Feature/CareStaffManagementTest.php b/tests/Feature/CareStaffManagementTest.php new file mode 100644 index 0000000..1baf740 --- /dev/null +++ b/tests/Feature/CareStaffManagementTest.php @@ -0,0 +1,247 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'staff-mgmt-owner', + 'name' => 'Owner', + 'email' => 'staff-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Staff Hospital', + 'slug' => 'staff-hospital', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'hospital', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ], + ]); + + $this->adminMember = 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, + ]); + + $nurse = User::create([ + 'public_id' => 'staff-nurse-ama', + 'name' => 'Nurse Ama', + 'email' => 'ama@example.com', + ]); + + $this->nurseMember = Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $nurse->public_id, + 'role' => 'midwife', + 'branch_id' => $this->branch->id, + ]); + } + + public function test_create_inpatient_unit_and_bed(): void + { + $this->actingAs($this->owner) + ->post(route('care.care-units.store'), [ + 'department_id' => $this->department->id, + 'name' => 'Postnatal Ward', + 'code' => 'PN', + 'kind' => 'inpatient', + ]) + ->assertRedirect(); + + $unit = CareUnit::query()->where('name', 'Postnatal Ward')->first(); + $this->assertNotNull($unit); + $this->assertTrue($unit->supportsBeds()); + + $this->actingAs($this->owner) + ->post(route('care.care-units.beds.store', $unit), [ + 'label' => 'PN-01', + 'room' => 'A', + 'status' => 'available', + ]) + ->assertRedirect(route('care.care-units.show', $unit)); + + $this->assertDatabaseHas('care_beds', [ + 'care_unit_id' => $unit->id, + 'label' => 'PN-01', + 'status' => 'available', + ]); + } + + public function test_outpatient_unit_rejects_beds(): void + { + $unit = CareUnit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'department_id' => $this->department->id, + 'name' => 'ANC Clinic', + 'kind' => 'outpatient', + 'is_active' => true, + ]); + + $this->actingAs($this->owner) + ->post(route('care.care-units.beds.store', $unit), [ + 'label' => 'X-1', + 'status' => 'available', + ]) + ->assertStatus(422); + + $this->assertSame(0, Bed::query()->where('care_unit_id', $unit->id)->count()); + } + + public function test_primary_and_temporary_assignments(): void + { + $floatDept = Department::create([ + 'owner_ref' => $this->owner->public_id, + 'branch_id' => $this->branch->id, + 'name' => 'Nursing Services', + 'type' => 'nursing', + 'is_active' => true, + ]); + + $floatPool = CareUnit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'department_id' => $floatDept->id, + 'name' => 'Float Pool', + 'kind' => 'float_pool', + 'is_active' => true, + ]); + + $labour = 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, + ]); + + $this->actingAs($this->owner) + ->post(route('care.staff-assignments.store'), [ + 'member_id' => $this->nurseMember->id, + 'department_id' => $floatDept->id, + 'care_unit_id' => $floatPool->id, + 'kind' => 'primary', + 'assignment_role' => 'float_nurse', + 'shift_code' => 'rotating', + 'starts_on' => now()->toDateString(), + 'status' => 'active', + ]) + ->assertRedirect(route('care.staff-assignments.index')); + + $this->actingAs($this->owner) + ->post(route('care.staff-assignments.store'), [ + 'member_id' => $this->nurseMember->id, + 'department_id' => $this->department->id, + 'care_unit_id' => $labour->id, + 'kind' => 'temporary', + 'assignment_role' => 'midwife', + 'shift_code' => 'night', + 'starts_on' => now()->toDateString(), + 'ends_on' => now()->toDateString(), + 'status' => 'active', + 'notes' => 'Covering labour tonight', + ]) + ->assertRedirect(route('care.staff-assignments.index')); + + $this->assertSame(2, StaffAssignment::query()->where('member_id', $this->nurseMember->id)->count()); + + $effective = StaffAssignment::query() + ->where('member_id', $this->nurseMember->id) + ->activeStatus() + ->effectiveOn(now()) + ->count(); + $this->assertSame(2, $effective); + + $this->actingAs($this->owner) + ->get(route('care.staff-assignments.index')) + ->assertOk() + ->assertSee('Float Pool') + ->assertSee('Labour Ward'); + } + + public function test_temporary_assignment_requires_unit(): void + { + $this->actingAs($this->owner) + ->from(route('care.staff-assignments.create')) + ->post(route('care.staff-assignments.store'), [ + 'member_id' => $this->nurseMember->id, + 'kind' => 'temporary', + 'starts_on' => now()->toDateString(), + 'status' => 'active', + ]) + ->assertRedirect(route('care.staff-assignments.create')) + ->assertSessionHasErrors('care_unit_id'); + } + + public function test_care_units_index_lists_units(): void + { + CareUnit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'department_id' => $this->department->id, + 'name' => 'ANC Clinic', + 'kind' => 'outpatient', + 'is_active' => true, + ]); + + $this->actingAs($this->owner) + ->get(route('care.care-units.index')) + ->assertOk() + ->assertSee('ANC Clinic') + ->assertSee('Outpatient'); + } +}