Add Care Units, beds, and dated staff assignments.
Deploy Ladill Care / deploy (push) Successful in 1m8s

Models employment as department/unit/shift placements with primary and temporary kinds so nurses are not permanently bound to a ward.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 10:04:39 +00:00
co-authored by Cursor
parent 56a663a777
commit 3735be3425
21 changed files with 1595 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Bed;
use App\Models\CareUnit;
use App\Services\Care\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class BedController extends Controller
{
use ScopesToAccount;
public function store(Request $request, CareUnit $careUnit): RedirectResponse
{
$this->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.');
}
}
@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\CareUnit;
use App\Models\Department;
use App\Services\Care\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class CareUnitController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->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.');
}
}
@@ -0,0 +1,254 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\CareUnit;
use App\Models\Department;
use App\Models\Member;
use App\Models\StaffAssignment;
use App\Models\User;
use App\Services\Care\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class StaffAssignmentController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->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<string, mixed>
*/
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<string, mixed>
*/
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<Member|null> $members
* @return array<int, string>
*/
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;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Bed extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_beds';
protected $fillable = [
'owner_ref',
'organization_id',
'care_unit_id',
'label',
'room',
'status',
'is_active',
'sort_order',
];
protected function casts(): array
{
return [
'is_active' => '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');
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
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 CareUnit extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_care_units';
protected $fillable = [
'owner_ref',
'organization_id',
'department_id',
'name',
'code',
'kind',
'is_active',
'sort_order',
];
protected function casts(): array
{
return [
'is_active' => '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;
}
}
+8
View File
@@ -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.
*/
+6
View File
@@ -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);
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class StaffAssignment extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_staff_assignments';
protected $fillable = [
'owner_ref',
'organization_id',
'member_id',
'department_id',
'care_unit_id',
'kind',
'assignment_role',
'shift_code',
'starts_on',
'ends_on',
'status',
'notes',
];
protected function casts(): array
{
return [
'starts_on' => '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';
}
}
+64
View File
@@ -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',
],
/*
@@ -0,0 +1,86 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Staff management foundations:
* Department Care Unit Bed (inpatient)
* Member Staff Assignment (dated, primary/temporary/specialty)
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('care_care_units', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,39 @@
<x-app-layout title="Add care unit">
<div class="mx-auto max-w-lg">
<h1 class="text-xl font-semibold text-slate-900">Add care unit</h1>
<p class="mt-1 text-sm text-slate-500">Place the unit under a department. Use Float pool for nurses without a permanent ward.</p>
<form method="POST" action="{{ route('care.care-units.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
<div>
<label class="block text-sm font-medium">Department</label>
<select name="department_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Select…</option>
@foreach ($departments as $department)
<option value="{{ $department->id }}" @selected((int) old('department_id', $selectedDepartmentId) === (int) $department->id)>
{{ $department->labelForSelect() }}
</option>
@endforeach
</select>
@error('department_id')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-sm font-medium">Name</label>
<input type="text" name="name" value="{{ old('name') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="e.g. Labour Ward, ANC Clinic">
@error('name')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-sm font-medium">Code <span class="font-normal text-slate-400">(optional)</span></label>
<input type="text" name="code" value="{{ old('code') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="LW, ANC">
</div>
<div>
<label class="block text-sm font-medium">Kind</label>
<select name="kind" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($kinds as $value => $label)
<option value="{{ $value }}" @selected(old('kind', 'inpatient') === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<button type="submit" class="btn-primary w-full">Create care unit</button>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,36 @@
<x-app-layout title="Edit care unit">
<div class="mx-auto max-w-lg">
<h1 class="text-xl font-semibold text-slate-900">Edit care unit</h1>
<form method="POST" action="{{ route('care.care-units.update', $unit) }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @method('PUT')
<div>
<label class="block text-sm font-medium">Department</label>
<select name="department_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($departments as $department)
<option value="{{ $department->id }}" @selected((int) old('department_id', $unit->department_id) === (int) $department->id)>
{{ $department->labelForSelect() }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium">Name</label>
<input type="text" name="name" value="{{ old('name', $unit->name) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium">Code</label>
<input type="text" name="code" value="{{ old('code', $unit->code) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium">Kind</label>
<select name="kind" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($kinds as $value => $label)
<option value="{{ $value }}" @selected(old('kind', $unit->kind) === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" @checked(old('is_active', $unit->is_active))> Active</label>
<button type="submit" class="btn-primary w-full">Save changes</button>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,52 @@
<x-app-layout title="Care units">
<div class="space-y-6">
<x-care.page-hero
badge="Inpatient · Clinics · Float pool"
title="Care units"
description="Units under departments — wards, clinics, theatres, and float pools. Staff are assigned to units with effective dates, not permanently bound to a ward."
:stats="[
['value' => number_format($heroStats['total']), 'label' => 'Units'],
['value' => number_format($heroStats['inpatient']), 'label' => 'Inpatient'],
['value' => number_format($heroStats['float']), 'label' => 'Float pools'],
['value' => number_format($heroStats['active']), 'label' => 'Active'],
]">
<x-slot name="actions">
<a href="{{ route('care.care-units.create') }}" class="btn-primary">Add care unit</a>
</x-slot>
</x-care.page-hero>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Kind</th>
<th class="px-4 py-3">Department</th>
<th class="px-4 py-3">Branch</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@forelse ($units as $unit)
<tr class="{{ $unit->is_active ? '' : 'opacity-60' }}">
<td class="px-4 py-3 font-medium">
{{ $unit->name }}
@if ($unit->code)
<span class="ml-1 text-xs text-slate-400">{{ $unit->code }}</span>
@endif
</td>
<td class="px-4 py-3">{{ $kinds[$unit->kind] ?? $unit->kind }}</td>
<td class="px-4 py-3">{{ $unit->department?->name }}</td>
<td class="px-4 py-3">{{ $unit->department?->branch?->name }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('care.care-units.show', $unit) }}" class="text-sky-600">Open</a>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500">No care units yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,83 @@
<x-app-layout title="{{ $unit->name }}">
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-sm text-slate-500">
<a href="{{ route('care.care-units.index') }}" class="text-indigo-600 hover:text-indigo-800">Care units</a>
<span class="text-slate-300">/</span>
{{ $unit->department?->name }}
</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">{{ $unit->name }}</h1>
<p class="mt-1 text-sm text-slate-500">
{{ $kinds[$unit->kind] ?? $unit->kind }}
@if ($unit->code) · {{ $unit->code }} @endif
· {{ $unit->department?->branch?->name }}
@unless ($unit->is_active)
· <span class="text-amber-700">Inactive</span>
@endunless
</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ route('care.care-units.edit', $unit) }}" class="btn-secondary">Edit</a>
<a href="{{ route('care.staff-assignments.create', ['care_unit_id' => $unit->id]) }}" class="btn-primary">Assign staff</a>
</div>
</div>
@if ($unit->supportsBeds())
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold text-slate-900">Beds</h2>
<p class="text-sm text-slate-500">Rooms and bed labels for this inpatient unit.</p>
</div>
</div>
<form method="POST" action="{{ route('care.care-units.beds.store', $unit) }}" class="mt-4 grid gap-3 sm:grid-cols-4">
@csrf
<input type="text" name="label" required placeholder="Bed label" class="rounded-lg border-slate-300 text-sm" value="{{ old('label') }}">
<input type="text" name="room" placeholder="Room" class="rounded-lg border-slate-300 text-sm" value="{{ old('room') }}">
<select name="status" class="rounded-lg border-slate-300 text-sm">
@foreach ($bedStatuses as $value => $label)
<option value="{{ $value }}" @selected(old('status', 'available') === $value)>{{ $label }}</option>
@endforeach
</select>
<button type="submit" class="btn-primary">Add bed</button>
</form>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-left text-xs uppercase text-slate-500">
<tr>
<th class="pb-2 pr-4">Label</th>
<th class="pb-2 pr-4">Room</th>
<th class="pb-2 pr-4">Status</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($unit->beds as $bed)
<tr class="{{ $bed->is_active ? '' : 'opacity-50' }}">
<td class="py-3 pr-4 font-medium">{{ $bed->label }}</td>
<td class="py-3 pr-4">{{ $bed->room ?: '—' }}</td>
<td class="py-3 pr-4">{{ $bedStatuses[$bed->status] ?? $bed->status }}</td>
<td class="py-3 text-right">
<form method="POST" action="{{ route('care.care-units.beds.destroy', [$unit, $bed]) }}" class="inline" onsubmit="return confirm('Remove this bed?')">
@csrf @method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800">Remove</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="4" class="py-4 text-slate-500">No beds yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@else
<div class="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-6 py-5 text-sm text-slate-600">
This unit kind does not use beds. Outpatient clinics, service units, and float pools assign staff without bed inventory.
</div>
@endif
</div>
</x-app-layout>
@@ -10,6 +10,7 @@
['value' => number_format($heroStats['branches']), 'label' => 'Branches'],
]">
<x-slot name="actions">
<a href="{{ route('care.care-units.index') }}" class="btn-secondary">Care units</a>
<a href="{{ route('care.departments.create') }}" class="btn-primary">Add department</a>
</x-slot>
</x-care.page-hero>
@@ -0,0 +1,122 @@
@php
$assignment = $assignment ?? null;
@endphp
<x-app-layout title="{{ $assignment ? 'Edit assignment' : 'Add assignment' }}">
<div class="mx-auto max-w-xl">
<h1 class="text-xl font-semibold text-slate-900">{{ $assignment ? 'Edit staff assignment' : 'Add staff assignment' }}</h1>
<p class="mt-1 text-sm text-slate-500">RBAC role stays on the team member. This record is their unit placement over a date range.</p>
<form method="POST"
action="{{ $assignment ? route('care.staff-assignments.update', $assignment) : route('care.staff-assignments.store') }}"
class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
@if ($assignment) @method('PUT') @endif
<div>
<label class="block text-sm font-medium">Staff member</label>
<select name="member_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Select…</option>
@foreach ($members as $member)
<option value="{{ $member->id }}" @selected((int) old('member_id', $assignment?->member_id ?? $selectedMemberId) === (int) $member->id)>
{{ $memberLabels[$member->id] ?? $member->user_ref }}
</option>
@endforeach
</select>
@error('member_id')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-sm font-medium">Assignment kind</label>
<select name="kind" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($kinds as $value => $label)
<option value="{{ $value }}" @selected(old('kind', $assignment?->kind ?? 'primary') === $value)>{{ $label }}</option>
@endforeach
</select>
<p class="mt-1 text-xs text-slate-500">Temporary requires a care unit (e.g. float nurse covering Emergency today).</p>
</div>
<div>
<label class="block text-sm font-medium">Department <span class="font-normal text-slate-400">(optional if unit selected)</span></label>
<select name="department_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value=""></option>
@foreach ($departments as $department)
<option value="{{ $department->id }}" @selected((int) old('department_id', $assignment?->department_id) === (int) $department->id)>
{{ $department->labelForSelect() }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium">Care unit</label>
<select name="care_unit_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value=""></option>
@foreach ($units as $unit)
<option value="{{ $unit->id }}"
data-department="{{ $unit->department_id }}"
@selected((int) old('care_unit_id', $assignment?->care_unit_id ?? request('care_unit_id')) === (int) $unit->id)>
{{ $unit->labelForSelect() }}
</option>
@endforeach
</select>
@error('care_unit_id')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium">Post / grade</label>
<select name="assignment_role" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value=""></option>
@foreach ($assignmentRoles as $value => $label)
<option value="{{ $value }}" @selected(old('assignment_role', $assignment?->assignment_role) === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium">Shift</label>
<select name="shift_code" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value=""></option>
@foreach ($shiftCodes as $value => $label)
<option value="{{ $value }}" @selected(old('shift_code', $assignment?->shift_code) === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium">Starts on</label>
<input type="date" name="starts_on" required value="{{ old('starts_on', $assignment?->starts_on?->format('Y-m-d') ?? now()->toDateString()) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium">Ends on <span class="font-normal text-slate-400">(open-ended OK)</span></label>
<input type="date" name="ends_on" value="{{ old('ends_on', $assignment?->ends_on?->format('Y-m-d')) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
</div>
<div>
<label class="block text-sm font-medium">Status</label>
<select name="status" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($statuses as $value => $label)
<option value="{{ $value }}" @selected(old('status', $assignment?->status ?? 'active') === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium">Notes</label>
<textarea name="notes" rows="2" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('notes', $assignment?->notes) }}</textarea>
</div>
<button type="submit" class="btn-primary w-full">{{ $assignment ? 'Save changes' : 'Create assignment' }}</button>
</form>
@if ($assignment)
<form method="POST" action="{{ route('care.staff-assignments.destroy', $assignment) }}" class="mt-4" onsubmit="return confirm('Remove this assignment?')">
@csrf @method('DELETE')
<button type="submit" class="text-sm font-medium text-red-600 hover:text-red-800">Remove assignment</button>
</form>
@endif
</div>
</x-app-layout>
@@ -0,0 +1 @@
@include('care.admin.staff-assignments.create')
@@ -0,0 +1,67 @@
<x-app-layout title="Staff assignments">
<div class="space-y-6">
<x-care.page-hero
badge="Primary · Temporary · Specialty"
title="Staff assignments"
description="Dated employment placements — department, care unit, post, and shift. Temporary assignments cover float nurses without making ward ownership permanent."
:stats="[
['value' => number_format($heroStats['total']), 'label' => 'Assignments'],
['value' => number_format($heroStats['active']), 'label' => 'Effective today'],
['value' => number_format($heroStats['primary']), 'label' => 'Primary'],
['value' => number_format($heroStats['temporary']), 'label' => 'Temporary'],
]">
<x-slot name="actions">
<a href="{{ route('care.staff-assignments.create') }}" class="btn-primary">Add assignment</a>
</x-slot>
</x-care.page-hero>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th class="px-4 py-3">Staff</th>
<th class="px-4 py-3">Kind</th>
<th class="px-4 py-3">Post</th>
<th class="px-4 py-3">Unit / Dept</th>
<th class="px-4 py-3">Shift</th>
<th class="px-4 py-3">Dates</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@forelse ($assignments as $assignment)
<tr>
<td class="px-4 py-3 font-medium">{{ $memberLabels[$assignment->member_id] ?? ('#'.$assignment->member_id) }}</td>
<td class="px-4 py-3">{{ $kinds[$assignment->kind] ?? $assignment->kind }}</td>
<td class="px-4 py-3">{{ $assignmentRoles[$assignment->assignment_role] ?? ($assignment->assignment_role ?: '—') }}</td>
<td class="px-4 py-3">
{{ $assignment->careUnit?->name ?? '—' }}
@if ($assignment->department)
<div class="text-xs text-slate-400">{{ $assignment->department->name }}</div>
@endif
</td>
<td class="px-4 py-3">{{ $shiftCodes[$assignment->shift_code] ?? ($assignment->shift_code ?: '—') }}</td>
<td class="px-4 py-3 whitespace-nowrap">
{{ $assignment->starts_on->format('Y-m-d') }}
{{ $assignment->ends_on?->format('Y-m-d') ?? 'open' }}
</td>
<td class="px-4 py-3">
{{ $statuses[$assignment->status] ?? $assignment->status }}
@if ($assignment->isEffectiveOn())
<span class="ml-1 text-xs text-emerald-600">today</span>
@endif
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('care.staff-assignments.edit', $assignment) }}" class="text-sky-600">Edit</a>
</td>
</tr>
@empty
<tr><td colspan="8" class="px-4 py-6 text-center text-slate-500">No staff assignments yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</x-app-layout>
@@ -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' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />'];
$adminNav[] = ['name' => 'Care units', 'route' => route('care.care-units.index'), 'active' => request()->routeIs('care.care-units.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z" />'];
}
if ($permissions->can($member, 'admin.members.view')) {
$adminNav[] = ['name' => 'Staff assignments', 'route' => route('care.staff-assignments.index'), 'active' => request()->routeIs('care.staff-assignments.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />'];
}
if ($permissions->can($member, 'admin.practitioners.view')) {
$adminNav[] = ['name' => 'Practitioners', 'route' => route('care.practitioners.index'), 'active' => request()->routeIs('care.practitioners.*'),
+21
View File
@@ -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');
+247
View File
@@ -0,0 +1,247 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Bed;
use App\Models\Branch;
use App\Models\CareUnit;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\StaffAssignment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CareStaffManagementTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected Department $department;
protected Member $adminMember;
protected Member $nurseMember;
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}