Add Care Units, beds, and dated staff assignments.
Deploy Ladill Care / deploy (push) Successful in 1m8s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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.*'),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user