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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user