Add shift templates, unit duty roster grid, and Nursing Services hub.
Deploy Ladill Care / deploy (push) Successful in 1m0s

Phase 3 staff management: real shift entities, week roster linked to temporary assignments, and a nursing ops module surface (registry, today’s allocation, unit shortcuts).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 10:40:25 +00:00
co-authored by Cursor
parent 9eb6c21828
commit b2cebe2908
20 changed files with 1439 additions and 8 deletions
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Services\Care\CarePermissions;
use App\Services\Care\NursingServicesService;
use App\Services\Care\RosterService;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NursingServicesController extends Controller
{
use ScopesToAccount;
public function index(
Request $request,
NursingServicesService $nursing,
RosterService $roster,
SpecialtyModuleService $modules,
): View {
$this->authorizeAbility($request, 'nursing.services.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$nurses = $nursing->nurseRegistry($organization, $owner);
$allocations = $nursing->activeAllocations($organization, $owner);
$todayRoster = $roster->todayAllocation($organization, $owner);
$units = $nursing->careUnits($organization, $owner);
$moduleEnabled = $modules->isEnabled($organization, 'nursing_services');
$labelMembers = $nurses
->merge($allocations->pluck('member')->filter())
->merge($todayRoster->pluck('member')->filter())
->unique('id')
->values();
$permissions = app(CarePermissions::class);
$member = $this->member($request);
return view('care.nursing.services-hub', [
'organization' => $organization,
'nurses' => $nurses,
'nurseLabels' => $nursing->memberLabels($labelMembers),
'allocations' => $allocations,
'todayRoster' => $todayRoster,
'units' => $units,
'moduleEnabled' => $moduleEnabled,
'roles' => config('care.roles'),
'canRoster' => $permissions->can($member, 'nursing.roster.manage'),
'canViewRoster' => $permissions->can($member, 'nursing.roster.view'),
'assignmentRoles' => config('care.staff_assignment_roles'),
'shiftCodes' => config('care.staff_shift_codes'),
]);
}
}
@@ -0,0 +1,103 @@
<?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\Member;
use App\Models\RosterEntry;
use App\Models\Shift;
use App\Services\Care\CarePermissions;
use App\Services\Care\RosterService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RosterController extends Controller
{
use ScopesToAccount;
public function show(Request $request, CareUnit $careUnit, RosterService $roster): View
{
$this->authorizeAbility($request, 'nursing.roster.view');
$this->authorizeOwner($request, $careUnit);
abort_unless((int) $careUnit->organization_id === (int) $this->organization($request)->id, 404);
$weekStart = $request->filled('week')
? Carbon::parse($request->input('week'))->startOfWeek(Carbon::MONDAY)
: now()->startOfWeek(Carbon::MONDAY);
$grid = $roster->unitWeekGrid($careUnit, $this->ownerRef($request), $weekStart);
$canManage = app(CarePermissions::class)->can($this->member($request), 'nursing.roster.manage');
return view('care.nursing.roster', [
'unit' => $careUnit->load('department.branch'),
'weekStart' => $grid['week_start'],
'days' => $grid['days'],
'shifts' => $grid['shifts'],
'cells' => $grid['cells'],
'members' => $grid['members'],
'memberLabels' => $grid['member_labels'],
'canManage' => $canManage,
'statuses' => config('care.roster_entry_statuses'),
'prevWeek' => $grid['week_start']->copy()->subWeek()->toDateString(),
'nextWeek' => $grid['week_start']->copy()->addWeek()->toDateString(),
]);
}
public function store(Request $request, CareUnit $careUnit, RosterService $roster): RedirectResponse
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$this->authorizeOwner($request, $careUnit);
$organization = $this->organization($request);
abort_unless((int) $careUnit->organization_id === (int) $organization->id, 404);
$validated = $request->validate([
'shift_id' => ['required', 'integer', 'exists:care_shifts,id'],
'member_id' => ['required', 'integer', 'exists:care_members,id'],
'duty_date' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:1000'],
]);
$shift = Shift::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->findOrFail($validated['shift_id']);
$member = Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->findOrFail($validated['member_id']);
$roster->assign(
$careUnit,
$shift,
$member,
$validated['duty_date'],
$this->ownerRef($request),
$this->actorRef($request),
$validated['notes'] ?? null,
);
return redirect()
->route('care.care-units.roster', [
'careUnit' => $careUnit,
'week' => Carbon::parse($validated['duty_date'])->startOfWeek(Carbon::MONDAY)->toDateString(),
])
->with('success', 'Staff added to roster.');
}
public function destroy(Request $request, CareUnit $careUnit, RosterEntry $rosterEntry, RosterService $roster): RedirectResponse
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$this->authorizeOwner($request, $careUnit);
$this->authorizeOwner($request, $rosterEntry);
abort_unless((int) $rosterEntry->care_unit_id === (int) $careUnit->id, 404);
$week = $rosterEntry->duty_date->copy()->startOfWeek(Carbon::MONDAY)->toDateString();
$roster->cancel($rosterEntry, $this->ownerRef($request), $this->actorRef($request));
return redirect()
->route('care.care-units.roster', ['careUnit' => $careUnit, 'week' => $week])
->with('success', 'Roster entry cancelled.');
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Shift;
use App\Services\Care\AuditLogger;
use App\Services\Care\RosterService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class ShiftController extends Controller
{
use ScopesToAccount;
public function index(Request $request, RosterService $roster): View
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$roster->ensureDefaultShifts($organization, $owner);
$shifts = Shift::owned($owner)
->where('organization_id', $organization->id)
->orderBy('sort_order')
->orderBy('label')
->get();
return view('care.nursing.shifts-index', [
'shifts' => $shifts,
'organization' => $organization,
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'code' => [
'required', 'string', 'max:50', 'alpha_dash',
Rule::unique('care_shifts', 'code')->where(fn ($q) => $q->where('organization_id', $organization->id)->whereNull('deleted_at')),
],
'label' => ['required', 'string', 'max:100'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i'],
'color' => ['nullable', 'string', 'max:20'],
]);
$shift = Shift::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
'code' => strtolower($validated['code']),
'label' => $validated['label'],
'start_time' => $validated['start_time'].':00',
'end_time' => $validated['end_time'].':00',
'color' => $validated['color'] ?? '#64748b',
'sort_order' => ((int) Shift::query()->where('organization_id', $organization->id)->max('sort_order')) + 10,
'is_active' => true,
]);
AuditLogger::record($owner, 'shift.created', $organization->id, $owner, Shift::class, $shift->id);
return redirect()->route('care.shifts.index')->with('success', 'Shift created.');
}
public function update(Request $request, Shift $shift): RedirectResponse
{
$this->authorizeAbility($request, 'nursing.roster.manage');
$this->authorizeOwner($request, $shift);
$validated = $request->validate([
'label' => ['required', 'string', 'max:100'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i'],
'color' => ['nullable', 'string', 'max:20'],
'is_active' => ['boolean'],
]);
$shift->update([
'label' => $validated['label'],
'start_time' => $validated['start_time'].':00',
'end_time' => $validated['end_time'].':00',
'color' => $validated['color'] ?? $shift->color,
'is_active' => $request->boolean('is_active', true),
]);
AuditLogger::record($this->ownerRef($request), 'shift.updated', $shift->organization_id, $this->ownerRef($request), Shift::class, $shift->id);
return redirect()->route('care.shifts.index')->with('success', 'Shift updated.');
}
}
@@ -31,7 +31,12 @@ class SpecialtyModuleController extends Controller
SpecialtyModuleService $modules,
SpecialtyShellService $shell,
CareQueueBridge $queueBridge,
): View {
): View|RedirectResponse {
// Nursing Services is an ops hub (roster / registry), not a patient specialty shell.
if ($module === 'nursing_services') {
return redirect()->route('care.nursing.services');
}
// Everyone lands on the specialty list/index — never auto-open a patient.
return $this->renderShell($request, $module, 'visits', $modules, $shell, $queueBridge);
}