Add shift templates, unit duty roster grid, and Nursing Services hub.
Deploy Ladill Care / deploy (push) Successful in 1m0s
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:
@@ -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,
|
SpecialtyModuleService $modules,
|
||||||
SpecialtyShellService $shell,
|
SpecialtyShellService $shell,
|
||||||
CareQueueBridge $queueBridge,
|
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.
|
// Everyone lands on the specialty list/index — never auto-open a patient.
|
||||||
return $this->renderShell($request, $module, 'visits', $modules, $shell, $queueBridge);
|
return $this->renderShell($request, $module, 'visits', $modules, $shell, $queueBridge);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?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 RosterEntry extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_SCHEDULED = 'scheduled';
|
||||||
|
|
||||||
|
public const STATUS_CONFIRMED = 'confirmed';
|
||||||
|
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
protected $table = 'care_roster_entries';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref',
|
||||||
|
'organization_id',
|
||||||
|
'care_unit_id',
|
||||||
|
'shift_id',
|
||||||
|
'member_id',
|
||||||
|
'duty_date',
|
||||||
|
'status',
|
||||||
|
'staff_assignment_id',
|
||||||
|
'notes',
|
||||||
|
'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'duty_date' => 'date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function careUnit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CareUnit::class, 'care_unit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shift(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Shift::class, 'shift_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function member(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Member::class, 'member_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function staffAssignment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(StaffAssignment::class, 'staff_assignment_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?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 Shift extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOwner, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'care_shifts';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'owner_ref',
|
||||||
|
'organization_id',
|
||||||
|
'code',
|
||||||
|
'label',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'color',
|
||||||
|
'sort_order',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rosterEntries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(RosterEntry::class, 'shift_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function timeLabel(): string
|
||||||
|
{
|
||||||
|
$start = substr((string) $this->start_time, 0, 5);
|
||||||
|
$end = substr((string) $this->end_time, 0, 5);
|
||||||
|
|
||||||
|
return $start.'–'.$end;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ class CarePermissions
|
|||||||
'super_admin' => null, // all via admin
|
'super_admin' => null, // all via admin
|
||||||
'hospital_admin' => null,
|
'hospital_admin' => null,
|
||||||
'emergency_physician' => ['emergency', 'radiology', 'pathology', 'blood_bank', 'cardiology'],
|
'emergency_physician' => ['emergency', 'radiology', 'pathology', 'blood_bank', 'cardiology'],
|
||||||
'ed_nurse' => ['emergency', 'blood_bank', 'radiology', 'pathology'],
|
'ed_nurse' => ['emergency', 'blood_bank', 'radiology', 'pathology', 'nursing_services'],
|
||||||
'general_physician' => [
|
'general_physician' => [
|
||||||
'emergency', 'cardiology', 'pediatrics', 'ent', 'dermatology',
|
'emergency', 'cardiology', 'pediatrics', 'ent', 'dermatology',
|
||||||
'ophthalmology', 'vaccination', 'child_welfare',
|
'ophthalmology', 'vaccination', 'child_welfare',
|
||||||
@@ -48,7 +48,7 @@ class CarePermissions
|
|||||||
'ophthalmology', 'vaccination', 'child_welfare',
|
'ophthalmology', 'vaccination', 'child_welfare',
|
||||||
], // legacy → GP (desk specialists narrowed elsewhere)
|
], // legacy → GP (desk specialists narrowed elsewhere)
|
||||||
'surgeon' => ['surgery', 'radiology', 'pathology', 'blood_bank'],
|
'surgeon' => ['surgery', 'radiology', 'pathology', 'blood_bank'],
|
||||||
'theatre_nurse' => ['surgery', 'blood_bank'],
|
'theatre_nurse' => ['surgery', 'blood_bank', 'nursing_services'],
|
||||||
'radiologist' => ['radiology'],
|
'radiologist' => ['radiology'],
|
||||||
'radiographer' => ['radiology'],
|
'radiographer' => ['radiology'],
|
||||||
'lab_technician' => ['pathology', 'blood_bank'],
|
'lab_technician' => ['pathology', 'blood_bank'],
|
||||||
@@ -60,11 +60,11 @@ class CarePermissions
|
|||||||
'dentist' => ['dentistry'],
|
'dentist' => ['dentistry'],
|
||||||
'psychiatrist' => ['psychiatry'],
|
'psychiatrist' => ['psychiatry'],
|
||||||
'physiotherapist' => ['physiotherapy'],
|
'physiotherapist' => ['physiotherapy'],
|
||||||
'midwife' => ['womens_health'],
|
'midwife' => ['womens_health', 'nursing_services'],
|
||||||
'obstetrician' => ['womens_health'],
|
'obstetrician' => ['womens_health'],
|
||||||
'gynecologist' => ['womens_health'],
|
'gynecologist' => ['womens_health'],
|
||||||
'obgyn' => ['womens_health'],
|
'obgyn' => ['womens_health'],
|
||||||
'labour_ward_nurse' => ['womens_health'],
|
'labour_ward_nurse' => ['womens_health', 'nursing_services'],
|
||||||
'pediatrician' => ['pediatrics'],
|
'pediatrician' => ['pediatrics'],
|
||||||
'oncologist' => ['oncology', 'infusion'],
|
'oncologist' => ['oncology', 'infusion'],
|
||||||
'cardiologist' => ['cardiology'],
|
'cardiologist' => ['cardiology'],
|
||||||
@@ -75,15 +75,15 @@ class CarePermissions
|
|||||||
'podiatrist' => ['podiatry'],
|
'podiatrist' => ['podiatry'],
|
||||||
'fertility_specialist' => ['fertility'],
|
'fertility_specialist' => ['fertility'],
|
||||||
'embryologist' => ['fertility'],
|
'embryologist' => ['fertility'],
|
||||||
'fertility_nurse' => ['fertility'],
|
'fertility_nurse' => ['fertility', 'nursing_services'],
|
||||||
'dialysis_nurse' => ['renal'],
|
'dialysis_nurse' => ['renal', 'nursing_services'],
|
||||||
'ambulance_staff' => ['ambulance', 'emergency'],
|
'ambulance_staff' => ['ambulance', 'emergency'],
|
||||||
'receptionist' => [], // refer only — see roleReferApps
|
'receptionist' => [], // refer only — see roleReferApps
|
||||||
'billing_officer' => [],
|
'billing_officer' => [],
|
||||||
'cashier' => [],
|
'cashier' => [],
|
||||||
'accountant' => [],
|
'accountant' => [],
|
||||||
'department_manager' => null, // department analytics — all enabled modules view
|
'department_manager' => null, // department analytics — all enabled modules view
|
||||||
'nurse' => ['vaccination', 'child_welfare', 'infusion', 'womens_health'],
|
'nurse' => ['vaccination', 'child_welfare', 'infusion', 'womens_health', 'nursing_services'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -593,6 +593,9 @@ class CarePermissions
|
|||||||
$abilities[] = 'nursing.assessments.view';
|
$abilities[] = 'nursing.assessments.view';
|
||||||
$abilities[] = 'nursing.assessments.manage';
|
$abilities[] = 'nursing.assessments.manage';
|
||||||
$abilities[] = 'nursing.performance.view';
|
$abilities[] = 'nursing.performance.view';
|
||||||
|
$abilities[] = 'nursing.roster.view';
|
||||||
|
$abilities[] = 'nursing.roster.manage';
|
||||||
|
$abilities[] = 'nursing.services.view';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array('analytics.department.view', $abilities, true)) {
|
if (in_array('analytics.department.view', $abilities, true)) {
|
||||||
@@ -602,6 +605,8 @@ class CarePermissions
|
|||||||
$abilities[] = 'nursing.handover.view';
|
$abilities[] = 'nursing.handover.view';
|
||||||
$abilities[] = 'nursing.assessments.view';
|
$abilities[] = 'nursing.assessments.view';
|
||||||
$abilities[] = 'nursing.performance.view';
|
$abilities[] = 'nursing.performance.view';
|
||||||
|
$abilities[] = 'nursing.roster.view';
|
||||||
|
$abilities[] = 'nursing.services.view';
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique($abilities));
|
return array_values(array_unique($abilities));
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\CareUnit;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\StaffAssignment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class NursingServicesService
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
public const NURSING_ROLES = [
|
||||||
|
'nurse',
|
||||||
|
'ed_nurse',
|
||||||
|
'theatre_nurse',
|
||||||
|
'labour_ward_nurse',
|
||||||
|
'fertility_nurse',
|
||||||
|
'dialysis_nurse',
|
||||||
|
'midwife',
|
||||||
|
'ambulance_staff',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Member>
|
||||||
|
*/
|
||||||
|
public function nurseRegistry(Organization $organization, string $ownerRef): Collection
|
||||||
|
{
|
||||||
|
return Member::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereIn('role', self::NURSING_ROLES)
|
||||||
|
->with('branch')
|
||||||
|
->orderBy('role')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active placements for nursing staff today.
|
||||||
|
*
|
||||||
|
* @return Collection<int, StaffAssignment>
|
||||||
|
*/
|
||||||
|
public function activeAllocations(Organization $organization, string $ownerRef): Collection
|
||||||
|
{
|
||||||
|
return StaffAssignment::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->effectiveOn(now())
|
||||||
|
->whereHas('member', fn ($q) => $q->whereIn('role', self::NURSING_ROLES))
|
||||||
|
->with(['member', 'careUnit.department', 'department'])
|
||||||
|
->orderBy('care_unit_id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Care units useful for nursing ops (inpatient / outpatient / float / service).
|
||||||
|
*
|
||||||
|
* @return Collection<int, CareUnit>
|
||||||
|
*/
|
||||||
|
public function careUnits(Organization $organization, string $ownerRef): Collection
|
||||||
|
{
|
||||||
|
return CareUnit::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->with('department.branch')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Member> $members
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function memberLabels(Collection $members): array
|
||||||
|
{
|
||||||
|
return app(RosterService::class)->memberLabels($members);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\CareUnit;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\RosterEntry;
|
||||||
|
use App\Models\Shift;
|
||||||
|
use App\Models\StaffAssignment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class RosterService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Default org shifts (seeded once per organization).
|
||||||
|
*
|
||||||
|
* @var list<array{code: string, label: string, start_time: string, end_time: string, color: string, sort_order: int}>
|
||||||
|
*/
|
||||||
|
protected array $defaults = [
|
||||||
|
['code' => 'day', 'label' => 'Day shift', 'start_time' => '07:00:00', 'end_time' => '15:00:00', 'color' => '#0ea5e9', 'sort_order' => 10],
|
||||||
|
['code' => 'evening', 'label' => 'Evening shift', 'start_time' => '15:00:00', 'end_time' => '23:00:00', 'color' => '#f59e0b', 'sort_order' => 20],
|
||||||
|
['code' => 'night', 'label' => 'Night shift', 'start_time' => '23:00:00', 'end_time' => '07:00:00', 'color' => '#6366f1', 'sort_order' => 30],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Shift>
|
||||||
|
*/
|
||||||
|
public function ensureDefaultShifts(Organization $organization, string $ownerRef): Collection
|
||||||
|
{
|
||||||
|
foreach ($this->defaults as $row) {
|
||||||
|
Shift::query()->firstOrCreate(
|
||||||
|
[
|
||||||
|
'organization_id' => $organization->id,
|
||||||
|
'code' => $row['code'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'label' => $row['label'],
|
||||||
|
'start_time' => $row['start_time'],
|
||||||
|
'end_time' => $row['end_time'],
|
||||||
|
'color' => $row['color'],
|
||||||
|
'sort_order' => $row['sort_order'],
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Shift::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('label')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Week grid: days × shifts → list of roster entries.
|
||||||
|
*
|
||||||
|
* @return array{week_start: Carbon, days: list<Carbon>, shifts: Collection, cells: array<string, list<RosterEntry>>, members: Collection}
|
||||||
|
*/
|
||||||
|
public function unitWeekGrid(
|
||||||
|
CareUnit $unit,
|
||||||
|
string $ownerRef,
|
||||||
|
?CarbonInterface $weekStart = null,
|
||||||
|
): array {
|
||||||
|
$organization = $unit->organization ?? Organization::query()->findOrFail($unit->organization_id);
|
||||||
|
$shifts = $this->ensureDefaultShifts($organization, $ownerRef);
|
||||||
|
|
||||||
|
$start = Carbon::parse($weekStart ?? now())->startOfWeek(Carbon::MONDAY)->startOfDay();
|
||||||
|
$days = [];
|
||||||
|
for ($i = 0; $i < 7; $i++) {
|
||||||
|
$days[] = $start->copy()->addDays($i);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = RosterEntry::owned($ownerRef)
|
||||||
|
->where('care_unit_id', $unit->id)
|
||||||
|
->whereBetween('duty_date', [$start->toDateString(), $start->copy()->addDays(6)->toDateString()])
|
||||||
|
->where('status', '!=', RosterEntry::STATUS_CANCELLED)
|
||||||
|
->with(['member', 'shift'])
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$cells = [];
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$key = $entry->duty_date->toDateString().'|'.$entry->shift_id;
|
||||||
|
$cells[$key][] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
$members = Member::owned($ownerRef)
|
||||||
|
->where('organization_id', $unit->organization_id)
|
||||||
|
->orderBy('role')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'week_start' => $start,
|
||||||
|
'days' => $days,
|
||||||
|
'shifts' => $shifts,
|
||||||
|
'cells' => $cells,
|
||||||
|
'members' => $members,
|
||||||
|
'member_labels' => $this->memberLabels($members),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assign(
|
||||||
|
CareUnit $unit,
|
||||||
|
Shift $shift,
|
||||||
|
Member $member,
|
||||||
|
CarbonInterface|string $dutyDate,
|
||||||
|
string $ownerRef,
|
||||||
|
?string $actorRef = null,
|
||||||
|
?string $notes = null,
|
||||||
|
bool $linkAssignment = true,
|
||||||
|
): RosterEntry {
|
||||||
|
if ((int) $shift->organization_id !== (int) $unit->organization_id
|
||||||
|
|| (int) $member->organization_id !== (int) $unit->organization_id) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'member_id' => 'Staff and shift must belong to the same organization as the care unit.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = Carbon::parse($dutyDate)->toDateString();
|
||||||
|
|
||||||
|
$existing = RosterEntry::withTrashed()
|
||||||
|
->where('care_unit_id', $unit->id)
|
||||||
|
->where('shift_id', $shift->id)
|
||||||
|
->where('member_id', $member->id)
|
||||||
|
->whereDate('duty_date', $date)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing && ! $existing->trashed() && $existing->status !== RosterEntry::STATUS_CANCELLED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'member_id' => 'This staff member is already rostered on this shift for that date.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignmentId = null;
|
||||||
|
if ($linkAssignment) {
|
||||||
|
$assignment = StaffAssignment::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $unit->organization_id,
|
||||||
|
'member_id' => $member->id,
|
||||||
|
'department_id' => $unit->department_id,
|
||||||
|
'care_unit_id' => $unit->id,
|
||||||
|
'kind' => 'temporary',
|
||||||
|
'assignment_role' => null,
|
||||||
|
'shift_code' => $shift->code,
|
||||||
|
'starts_on' => $date,
|
||||||
|
'ends_on' => $date,
|
||||||
|
'status' => 'active',
|
||||||
|
'notes' => $notes ?? 'Duty roster',
|
||||||
|
]);
|
||||||
|
$assignmentId = $assignment->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
if ($existing->trashed()) {
|
||||||
|
$existing->restore();
|
||||||
|
}
|
||||||
|
$existing->update([
|
||||||
|
'status' => RosterEntry::STATUS_SCHEDULED,
|
||||||
|
'staff_assignment_id' => $assignmentId,
|
||||||
|
'notes' => $notes,
|
||||||
|
'created_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
$entry = $existing;
|
||||||
|
} else {
|
||||||
|
$entry = RosterEntry::create([
|
||||||
|
'owner_ref' => $ownerRef,
|
||||||
|
'organization_id' => $unit->organization_id,
|
||||||
|
'care_unit_id' => $unit->id,
|
||||||
|
'shift_id' => $shift->id,
|
||||||
|
'member_id' => $member->id,
|
||||||
|
'duty_date' => $date,
|
||||||
|
'status' => RosterEntry::STATUS_SCHEDULED,
|
||||||
|
'staff_assignment_id' => $assignmentId,
|
||||||
|
'notes' => $notes,
|
||||||
|
'created_by' => $actorRef,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'roster.assigned',
|
||||||
|
$unit->organization_id,
|
||||||
|
$actorRef,
|
||||||
|
RosterEntry::class,
|
||||||
|
$entry->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $entry->load(['member', 'shift']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(RosterEntry $entry, string $ownerRef, ?string $actorRef = null): void
|
||||||
|
{
|
||||||
|
$entryId = $entry->id;
|
||||||
|
$organizationId = $entry->organization_id;
|
||||||
|
|
||||||
|
$entry->update(['status' => RosterEntry::STATUS_CANCELLED]);
|
||||||
|
|
||||||
|
if ($entry->staff_assignment_id) {
|
||||||
|
StaffAssignment::query()->where('id', $entry->staff_assignment_id)->update([
|
||||||
|
'status' => 'ended',
|
||||||
|
'ends_on' => $entry->duty_date,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete so the unique (unit, shift, member, date) can be reused.
|
||||||
|
$entry->delete();
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$ownerRef,
|
||||||
|
'roster.cancelled',
|
||||||
|
$organizationId,
|
||||||
|
$actorRef,
|
||||||
|
RosterEntry::class,
|
||||||
|
$entryId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Today's allocation across units (or one unit).
|
||||||
|
*
|
||||||
|
* @return Collection<int, RosterEntry>
|
||||||
|
*/
|
||||||
|
public function todayAllocation(Organization $organization, string $ownerRef, ?int $careUnitId = null): Collection
|
||||||
|
{
|
||||||
|
return RosterEntry::owned($ownerRef)
|
||||||
|
->where('organization_id', $organization->id)
|
||||||
|
->whereDate('duty_date', now()->toDateString())
|
||||||
|
->where('status', '!=', RosterEntry::STATUS_CANCELLED)
|
||||||
|
->when($careUnitId, fn ($q) => $q->where('care_unit_id', $careUnitId))
|
||||||
|
->with(['member', 'shift', 'careUnit.department'])
|
||||||
|
->orderBy('care_unit_id')
|
||||||
|
->orderBy('shift_id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<Member> $members
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function memberLabels(iterable $members): array
|
||||||
|
{
|
||||||
|
$list = collect($members)->filter()->unique('id')->values();
|
||||||
|
$refs = $list->pluck('user_ref')->all();
|
||||||
|
$users = User::query()->whereIn('public_id', $refs)->get()->keyBy('public_id');
|
||||||
|
$roles = config('care.roles');
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
foreach ($list as $member) {
|
||||||
|
$user = $users->get($member->user_ref);
|
||||||
|
$display = $user?->name
|
||||||
|
?? (str_contains((string) $member->user_ref, '@') ? $member->user_ref : ($user?->email ?? $member->user_ref));
|
||||||
|
$labels[(int) $member->id] = $display.' · '.($roles[$member->role] ?? $member->role);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,13 @@ return [
|
|||||||
'rotating' => 'Rotating',
|
'rotating' => 'Rotating',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'roster_entry_statuses' => [
|
||||||
|
'scheduled' => 'Scheduled',
|
||||||
|
'confirmed' => 'Confirmed',
|
||||||
|
'completed' => 'Completed',
|
||||||
|
'cancelled' => 'Cancelled',
|
||||||
|
],
|
||||||
|
|
||||||
'mar_frequency_codes' => [
|
'mar_frequency_codes' => [
|
||||||
'od' => 'Once daily (OD)',
|
'od' => 'Once daily (OD)',
|
||||||
'bd' => 'Twice daily (BD)',
|
'bd' => 'Twice daily (BD)',
|
||||||
|
|||||||
@@ -495,4 +495,28 @@ return [
|
|||||||
],
|
],
|
||||||
'specialist_keywords' => ['ambulance', 'ems', 'paramedic', 'prehospital'],
|
'specialist_keywords' => ['ambulance', 'ems', 'paramedic', 'prehospital'],
|
||||||
],
|
],
|
||||||
|
'nursing_services' => [
|
||||||
|
'label' => 'Nursing Services',
|
||||||
|
'description' => 'Nursing administration hub: nurse registry, ward allocation, duty roster, and unit clinical shortcuts.',
|
||||||
|
'department_type' => 'nursing',
|
||||||
|
'department_name' => 'Nursing Services',
|
||||||
|
'queue_name' => 'Nursing',
|
||||||
|
'queue_prefix' => 'NUR',
|
||||||
|
'nav_label' => 'Nursing',
|
||||||
|
'icon' => 'user-group',
|
||||||
|
'queue_keywords' => ['nursing', 'nurse', 'matron', 'ward'],
|
||||||
|
'access' => 'general',
|
||||||
|
'roles' => [
|
||||||
|
'nurse', 'ed_nurse', 'theatre_nurse', 'labour_ward_nurse',
|
||||||
|
'fertility_nurse', 'dialysis_nurse', 'midwife',
|
||||||
|
],
|
||||||
|
'view_roles' => [
|
||||||
|
'nurse', 'ed_nurse', 'theatre_nurse', 'labour_ward_nurse',
|
||||||
|
'fertility_nurse', 'dialysis_nurse', 'midwife',
|
||||||
|
'department_manager', 'general_physician', 'doctor',
|
||||||
|
],
|
||||||
|
'refer_roles' => [],
|
||||||
|
'specialist_keywords' => ['nursing services', 'nursing', 'matron'],
|
||||||
|
'default_on_paid_plans' => true,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -910,5 +910,20 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Ops hub — patient shell is unused (specialty.show redirects to nursing hub).
|
||||||
|
'nursing_services' => [
|
||||||
|
'stages' => [
|
||||||
|
['code' => 'active', 'label' => 'On duty', 'queue_point' => null],
|
||||||
|
],
|
||||||
|
'services' => [],
|
||||||
|
'workspace_tabs' => [
|
||||||
|
'overview' => 'Overview',
|
||||||
|
],
|
||||||
|
'stage_tabs' => [
|
||||||
|
'active' => 'overview',
|
||||||
|
],
|
||||||
|
'actions' => [],
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3: org shift templates + duty roster entries (unit × date × shift).
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('care_shifts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('owner_ref')->index();
|
||||||
|
$table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete();
|
||||||
|
$table->string('code'); // day, evening, night, custom…
|
||||||
|
$table->string('label');
|
||||||
|
$table->time('start_time');
|
||||||
|
$table->time('end_time');
|
||||||
|
$table->string('color')->nullable();
|
||||||
|
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->unique(['organization_id', 'code']);
|
||||||
|
$table->index(['organization_id', 'is_active']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('care_roster_entries', 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->foreignId('shift_id')->constrained('care_shifts')->cascadeOnDelete();
|
||||||
|
$table->foreignId('member_id')->constrained('care_members')->cascadeOnDelete();
|
||||||
|
$table->date('duty_date');
|
||||||
|
/** scheduled | confirmed | completed | cancelled */
|
||||||
|
$table->string('status')->default('scheduled');
|
||||||
|
$table->foreignId('staff_assignment_id')->nullable()
|
||||||
|
->constrained('care_staff_assignments')->nullOnDelete();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->string('created_by')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->unique(['care_unit_id', 'shift_id', 'member_id', 'duty_date'], 'care_roster_unit_shift_member_date');
|
||||||
|
$table->index(['care_unit_id', 'duty_date']);
|
||||||
|
$table->index(['member_id', 'duty_date']);
|
||||||
|
$table->index(['organization_id', 'duty_date']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('care_roster_entries');
|
||||||
|
Schema::dropIfExists('care_shifts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -29,10 +29,14 @@
|
|||||||
$canHandover = $permissions->can($member, 'nursing.handover.view');
|
$canHandover = $permissions->can($member, 'nursing.handover.view');
|
||||||
$canAssess = $permissions->can($member, 'nursing.assessments.view');
|
$canAssess = $permissions->can($member, 'nursing.assessments.view');
|
||||||
$canPerf = $permissions->can($member, 'nursing.performance.view');
|
$canPerf = $permissions->can($member, 'nursing.performance.view');
|
||||||
|
$canRoster = $permissions->can($member, 'nursing.roster.view');
|
||||||
@endphp
|
@endphp
|
||||||
@if ($canMar)
|
@if ($canMar)
|
||||||
<a href="{{ route('care.care-units.mar', $unit) }}" class="btn-primary">MAR board</a>
|
<a href="{{ route('care.care-units.mar', $unit) }}" class="btn-primary">MAR board</a>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($canRoster)
|
||||||
|
<a href="{{ route('care.care-units.roster', $unit) }}" class="btn-secondary">Duty roster</a>
|
||||||
|
@endif
|
||||||
@if ($canAssess)
|
@if ($canAssess)
|
||||||
<a href="{{ route('care.care-units.assessments', $unit) }}" class="btn-secondary">Assessments</a>
|
<a href="{{ route('care.care-units.assessments', $unit) }}" class="btn-secondary">Assessments</a>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<x-app-layout title="Roster · {{ $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.show', $unit) }}" class="text-indigo-600 hover:text-indigo-800">{{ $unit->name }}</a>
|
||||||
|
<span class="text-slate-300">/</span>
|
||||||
|
Duty roster
|
||||||
|
</p>
|
||||||
|
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Week roster</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
{{ $weekStart->format('d M Y') }} – {{ $weekStart->copy()->addDays(6)->format('d M Y') }}
|
||||||
|
· {{ $unit->department?->name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<a href="{{ route('care.care-units.roster', ['careUnit' => $unit, 'week' => $prevWeek]) }}" class="btn-secondary">Previous</a>
|
||||||
|
<a href="{{ route('care.care-units.roster', ['careUnit' => $unit, 'week' => now()->startOfWeek(\Carbon\Carbon::MONDAY)->toDateString()]) }}" class="btn-secondary">This week</a>
|
||||||
|
<a href="{{ route('care.care-units.roster', ['careUnit' => $unit, 'week' => $nextWeek]) }}" class="btn-secondary">Next</a>
|
||||||
|
@if ($canManage)
|
||||||
|
<a href="{{ route('care.shifts.index') }}" class="btn-secondary">Shift templates</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto 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="sticky left-0 z-10 bg-slate-50 px-3 py-3">Shift</th>
|
||||||
|
@foreach ($days as $day)
|
||||||
|
<th class="min-w-[9rem] px-3 py-3 {{ $day->isToday() ? 'bg-indigo-50 text-indigo-700' : '' }}">
|
||||||
|
<div>{{ $day->format('D') }}</div>
|
||||||
|
<div class="font-normal normal-case text-slate-400">{{ $day->format('d M') }}</div>
|
||||||
|
</th>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
@foreach ($shifts as $shift)
|
||||||
|
<tr>
|
||||||
|
<td class="sticky left-0 z-10 bg-white px-3 py-3 align-top">
|
||||||
|
<div class="flex items-center gap-2 font-medium text-slate-900">
|
||||||
|
<span class="inline-block h-2.5 w-2.5 rounded-full" style="background: {{ $shift->color ?? '#64748b' }}"></span>
|
||||||
|
{{ $shift->label }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-xs text-slate-400">{{ $shift->timeLabel() }}</div>
|
||||||
|
</td>
|
||||||
|
@foreach ($days as $day)
|
||||||
|
@php
|
||||||
|
$key = $day->toDateString().'|'.$shift->id;
|
||||||
|
$entries = $cells[$key] ?? [];
|
||||||
|
@endphp
|
||||||
|
<td class="px-2 py-2 align-top {{ $day->isToday() ? 'bg-indigo-50/40' : '' }}">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
@forelse ($entries as $entry)
|
||||||
|
<div class="rounded-lg border border-slate-100 bg-slate-50 px-2 py-1.5">
|
||||||
|
<div class="text-xs font-medium text-slate-800">
|
||||||
|
{{ $memberLabels[$entry->member_id] ?? ($entry->member?->user_ref ?? 'Staff') }}
|
||||||
|
</div>
|
||||||
|
@if ($canManage)
|
||||||
|
<form method="POST" action="{{ route('care.care-units.roster.destroy', [$unit, $entry]) }}" class="mt-1" onsubmit="return confirm('Remove from roster?')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="text-[11px] text-red-600 hover:text-red-800">Remove</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="px-1 text-[11px] text-slate-300">—</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($canManage)
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900">Add to roster</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Creates a temporary staff assignment for that duty date.</p>
|
||||||
|
<form method="POST" action="{{ route('care.care-units.roster.store', $unit) }}" class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
@csrf
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Date</label>
|
||||||
|
<input type="date" name="duty_date" value="{{ old('duty_date', now()->toDateString()) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
@error('duty_date')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Shift</label>
|
||||||
|
<select name="shift_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
@foreach ($shifts as $shift)
|
||||||
|
<option value="{{ $shift->id }}" @selected(old('shift_id') == $shift->id)>{{ $shift->label }} ({{ $shift->timeLabel() }})</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('shift_id')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||||
|
</div>
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Staff</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(old('member_id') == $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 class="flex items-end">
|
||||||
|
<button type="submit" class="btn-primary w-full">Assign</button>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2 lg:col-span-5">
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Notes</label>
|
||||||
|
<input type="text" name="notes" value="{{ old('notes') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<x-app-layout title="Nursing Services">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900">Nursing Services</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
Nurse registry, today’s duty allocation, and shortcuts into ward boards.
|
||||||
|
@unless ($moduleEnabled)
|
||||||
|
<span class="text-amber-700">Module not enabled for this organization — enable under Settings → Modules.</span>
|
||||||
|
@endunless
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@if ($canRoster)
|
||||||
|
<a href="{{ route('care.shifts.index') }}" class="btn-secondary">Shift templates</a>
|
||||||
|
@endif
|
||||||
|
<a href="{{ route('care.care-units.index') }}" class="btn-secondary">Care units</a>
|
||||||
|
<a href="{{ route('care.staff-assignments.index') }}" class="btn-secondary">Staff assignments</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 lg:col-span-1">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Nurse registry</h2>
|
||||||
|
<p class="mt-1 text-2xl font-semibold text-slate-900">{{ $nurses->count() }}</p>
|
||||||
|
<ul class="mt-4 max-h-80 space-y-2 overflow-y-auto text-sm">
|
||||||
|
@forelse ($nurses as $nurse)
|
||||||
|
<li class="flex items-start justify-between gap-2 border-b border-slate-50 pb-2">
|
||||||
|
<span class="font-medium text-slate-800">{{ $nurseLabels[$nurse->id] ?? $nurse->user_ref }}</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="text-slate-500">No nursing-role members yet.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 lg:col-span-2">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Today’s roster</h2>
|
||||||
|
<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">Unit</th>
|
||||||
|
<th class="pb-2 pr-4">Shift</th>
|
||||||
|
<th class="pb-2">Staff</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
@forelse ($todayRoster as $entry)
|
||||||
|
<tr>
|
||||||
|
<td class="py-2.5 pr-4">
|
||||||
|
@if ($canViewRoster)
|
||||||
|
<a href="{{ route('care.care-units.roster', $entry->careUnit) }}" class="text-indigo-600 hover:text-indigo-800">
|
||||||
|
{{ $entry->careUnit?->name ?? '—' }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
{{ $entry->careUnit?->name ?? '—' }}
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 pr-4 whitespace-nowrap">
|
||||||
|
<span class="mr-1 inline-block h-2 w-2 rounded-full" style="background: {{ $entry->shift?->color ?? '#94a3b8' }}"></span>
|
||||||
|
{{ $entry->shift?->label ?? '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5">{{ $nurseLabels[$entry->member_id] ?? ($entry->member?->user_ref ?? '—') }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="3" class="py-6 text-slate-500">No duty roster entries for today.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Active allocations</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Dated staff assignments effective today (primary, temporary, specialty).</p>
|
||||||
|
<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">Staff</th>
|
||||||
|
<th class="pb-2 pr-4">Unit / department</th>
|
||||||
|
<th class="pb-2 pr-4">Kind</th>
|
||||||
|
<th class="pb-2">Dates</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
@forelse ($allocations as $assignment)
|
||||||
|
<tr>
|
||||||
|
<td class="py-2.5 pr-4 font-medium">
|
||||||
|
{{ $nurseLabels[$assignment->member_id] ?? ($assignment->member?->user_ref ?? '—') }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 pr-4">
|
||||||
|
{{ $assignment->careUnit?->name ?? $assignment->department?->name ?? '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 pr-4 capitalize">{{ $assignment->kind }}</td>
|
||||||
|
<td class="py-2.5 whitespace-nowrap text-slate-500">
|
||||||
|
{{ $assignment->starts_on?->format('d M Y') }}
|
||||||
|
–
|
||||||
|
{{ $assignment->ends_on?->format('d M Y') ?? 'open' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="4" class="py-6 text-slate-500">No active nursing allocations today.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Care units</h2>
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@forelse ($units as $unit)
|
||||||
|
<div class="rounded-xl border border-slate-100 px-4 py-3">
|
||||||
|
<a href="{{ route('care.care-units.show', $unit) }}" class="font-medium text-slate-900 hover:text-indigo-700">{{ $unit->name }}</a>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">{{ $unit->department?->name }} · {{ str_replace('_', ' ', $unit->kind) }}</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
|
@if ($canViewRoster)
|
||||||
|
<a href="{{ route('care.care-units.roster', $unit) }}" class="text-indigo-600 hover:text-indigo-800">Roster</a>
|
||||||
|
@endif
|
||||||
|
<a href="{{ route('care.care-units.mar', $unit) }}" class="text-slate-600 hover:text-slate-900">MAR</a>
|
||||||
|
<a href="{{ route('care.care-units.notes', $unit) }}" class="text-slate-600 hover:text-slate-900">Notes</a>
|
||||||
|
<a href="{{ route('care.care-units.handovers', $unit) }}" class="text-slate-600 hover:text-slate-900">Handover</a>
|
||||||
|
<a href="{{ route('care.care-units.assessments', $unit) }}" class="text-slate-600 hover:text-slate-900">Assess</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-sm text-slate-500 sm:col-span-2 lg:col-span-3">No care units yet — create units under Care units.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<x-app-layout title="Shift templates">
|
||||||
|
<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.nursing.services') }}" class="text-indigo-600 hover:text-indigo-800">Nursing Services</a>
|
||||||
|
<span class="text-slate-300">/</span>
|
||||||
|
Shifts
|
||||||
|
</p>
|
||||||
|
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Shift templates</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Organization-wide duty periods used on unit rosters.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">Label</th>
|
||||||
|
<th class="px-4 py-3">Code</th>
|
||||||
|
<th class="px-4 py-3">Hours</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">
|
||||||
|
@foreach ($shifts as $shift)
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="mr-2 inline-block h-2.5 w-2.5 rounded-full" style="background: {{ $shift->color ?? '#64748b' }}"></span>
|
||||||
|
<span class="font-medium">{{ $shift->label }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ $shift->code }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ $shift->timeLabel() }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@if ($shift->is_active)
|
||||||
|
<span class="text-emerald-700">Active</span>
|
||||||
|
@else
|
||||||
|
<span class="text-slate-400">Inactive</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<details class="relative inline-block text-left">
|
||||||
|
<summary class="cursor-pointer text-indigo-600 hover:text-indigo-800">Edit</summary>
|
||||||
|
<form method="POST" action="{{ route('care.shifts.update', $shift) }}" class="absolute right-0 z-10 mt-2 w-72 rounded-xl border border-slate-200 bg-white p-4 shadow-lg">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Label</label>
|
||||||
|
<input type="text" name="label" value="{{ old('label', $shift->label) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Start</label>
|
||||||
|
<input type="time" name="start_time" value="{{ old('start_time', substr((string) $shift->start_time, 0, 5)) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">End</label>
|
||||||
|
<input type="time" name="end_time" value="{{ old('end_time', substr((string) $shift->end_time, 0, 5)) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Color</label>
|
||||||
|
<input type="color" name="color" value="{{ old('color', $shift->color ?? '#64748b') }}" class="mt-1 h-9 w-full rounded-lg border border-slate-300">
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $shift->is_active)) class="rounded border-slate-300">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-primary w-full">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||||
|
<h2 class="text-base font-semibold text-slate-900">Add shift</h2>
|
||||||
|
<form method="POST" action="{{ route('care.shifts.store') }}" class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
@csrf
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Code</label>
|
||||||
|
<input type="text" name="code" value="{{ old('code') }}" required placeholder="e.g. long_day" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
@error('code')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Label</label>
|
||||||
|
<input type="text" name="label" value="{{ old('label') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
@error('label')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">Start</label>
|
||||||
|
<input type="time" name="start_time" value="{{ old('start_time', '07:00') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium uppercase text-slate-500">End</label>
|
||||||
|
<input type="time" name="end_time" value="{{ old('end_time', '19:00') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button type="submit" class="btn-primary w-full">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -119,6 +119,10 @@
|
|||||||
$adminNav[] = ['name' => 'Care units', 'route' => route('care.care-units.index'), 'active' => request()->routeIs('care.care-units.*'),
|
$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" />'];
|
'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, 'nursing.services.view')) {
|
||||||
|
$adminNav[] = ['name' => 'Nursing Services', 'route' => route('care.nursing.services'), 'active' => request()->routeIs('care.nursing.*') || request()->routeIs('care.shifts.*'),
|
||||||
|
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />'];
|
||||||
|
}
|
||||||
if ($permissions->can($member, 'admin.members.view')) {
|
if ($permissions->can($member, 'admin.members.view')) {
|
||||||
$adminNav[] = ['name' => 'Staff assignments', 'route' => route('care.staff-assignments.index'), 'active' => request()->routeIs('care.staff-assignments.*'),
|
$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" />'];
|
'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" />'];
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ use App\Http\Controllers\Care\MarController;
|
|||||||
use App\Http\Controllers\Care\NursingDocumentationController;
|
use App\Http\Controllers\Care\NursingDocumentationController;
|
||||||
use App\Http\Controllers\Care\NursingAssessmentController;
|
use App\Http\Controllers\Care\NursingAssessmentController;
|
||||||
use App\Http\Controllers\Care\NursePerformanceController;
|
use App\Http\Controllers\Care\NursePerformanceController;
|
||||||
|
use App\Http\Controllers\Care\ShiftController;
|
||||||
|
use App\Http\Controllers\Care\RosterController;
|
||||||
|
use App\Http\Controllers\Care\NursingServicesController;
|
||||||
use App\Http\Controllers\Care\DeviceController;
|
use App\Http\Controllers\Care\DeviceController;
|
||||||
use App\Http\Controllers\Care\DisplayPublicController;
|
use App\Http\Controllers\Care\DisplayPublicController;
|
||||||
use App\Http\Controllers\Care\DisplayScreenController;
|
use App\Http\Controllers\Care\DisplayScreenController;
|
||||||
@@ -454,6 +457,16 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
|||||||
Route::post('/care-units/{careUnit}/visits/{visit}/vitals', [NursingAssessmentController::class, 'storeVitals'])->name('care.care-units.vitals.store');
|
Route::post('/care-units/{careUnit}/visits/{visit}/vitals', [NursingAssessmentController::class, 'storeVitals'])->name('care.care-units.vitals.store');
|
||||||
Route::get('/care-units/{careUnit}/performance', [NursePerformanceController::class, 'show'])->name('care.care-units.performance');
|
Route::get('/care-units/{careUnit}/performance', [NursePerformanceController::class, 'show'])->name('care.care-units.performance');
|
||||||
|
|
||||||
|
Route::get('/care-units/{careUnit}/roster', [RosterController::class, 'show'])->name('care.care-units.roster');
|
||||||
|
Route::post('/care-units/{careUnit}/roster', [RosterController::class, 'store'])->name('care.care-units.roster.store');
|
||||||
|
Route::delete('/care-units/{careUnit}/roster/{rosterEntry}', [RosterController::class, 'destroy'])->name('care.care-units.roster.destroy');
|
||||||
|
|
||||||
|
Route::get('/shifts', [ShiftController::class, 'index'])->name('care.shifts.index');
|
||||||
|
Route::post('/shifts', [ShiftController::class, 'store'])->name('care.shifts.store');
|
||||||
|
Route::put('/shifts/{shift}', [ShiftController::class, 'update'])->name('care.shifts.update');
|
||||||
|
|
||||||
|
Route::get('/nursing/services', [NursingServicesController::class, 'index'])->name('care.nursing.services');
|
||||||
|
|
||||||
Route::get('/staff-assignments', [StaffAssignmentController::class, 'index'])->name('care.staff-assignments.index');
|
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::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::post('/staff-assignments', [StaffAssignmentController::class, 'store'])->name('care.staff-assignments.store');
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\CareUnit;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\RosterEntry;
|
||||||
|
use App\Models\Shift;
|
||||||
|
use App\Models\StaffAssignment;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Care\RosterService;
|
||||||
|
use App\Services\Care\SpecialtyModuleService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareRosterAndNursingServicesTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $owner;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected CareUnit $unit;
|
||||||
|
|
||||||
|
protected Member $nurseMember;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
$this->owner = User::create([
|
||||||
|
'public_id' => 'roster-owner',
|
||||||
|
'name' => 'Owner',
|
||||||
|
'email' => 'roster-owner@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization = Organization::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'name' => 'Roster Hospital',
|
||||||
|
'slug' => 'roster-hospital',
|
||||||
|
'settings' => [
|
||||||
|
'onboarded' => true,
|
||||||
|
'facility_type' => 'hospital',
|
||||||
|
'plan' => 'pro',
|
||||||
|
'plan_expires_at' => now()->addMonth()->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Member::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $this->owner->public_id,
|
||||||
|
'role' => 'hospital_admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$branch = Branch::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'name' => 'Main',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$department = Department::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'branch_id' => $branch->id,
|
||||||
|
'name' => 'Medicine',
|
||||||
|
'type' => 'general',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->unit = CareUnit::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'department_id' => $department->id,
|
||||||
|
'name' => 'Medical Ward',
|
||||||
|
'kind' => 'inpatient',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$nurseUser = User::create([
|
||||||
|
'public_id' => 'roster-nurse',
|
||||||
|
'name' => 'Nurse Ama',
|
||||||
|
'email' => 'roster-nurse@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->nurseMember = Member::create([
|
||||||
|
'owner_ref' => $this->owner->public_id,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
'user_ref' => $nurseUser->public_id,
|
||||||
|
'role' => 'nurse',
|
||||||
|
'branch_id' => $branch->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_default_shifts_are_seeded_on_index(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.shifts.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Day shift')
|
||||||
|
->assertSee('Evening shift')
|
||||||
|
->assertSee('Night shift');
|
||||||
|
|
||||||
|
$this->assertSame(3, Shift::query()->where('organization_id', $this->organization->id)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_roster_assign_links_temporary_staff_assignment(): void
|
||||||
|
{
|
||||||
|
$roster = app(RosterService::class);
|
||||||
|
$shifts = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id);
|
||||||
|
$day = $shifts->firstWhere('code', 'day');
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.care-units.roster.store', $this->unit), [
|
||||||
|
'shift_id' => $day->id,
|
||||||
|
'member_id' => $this->nurseMember->id,
|
||||||
|
'duty_date' => now()->toDateString(),
|
||||||
|
'notes' => 'Cover',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$entry = RosterEntry::query()->first();
|
||||||
|
$this->assertNotNull($entry);
|
||||||
|
$this->assertSame(RosterEntry::STATUS_SCHEDULED, $entry->status);
|
||||||
|
$this->assertNotNull($entry->staff_assignment_id);
|
||||||
|
|
||||||
|
$assignment = StaffAssignment::query()->find($entry->staff_assignment_id);
|
||||||
|
$this->assertSame('temporary', $assignment->kind);
|
||||||
|
$this->assertSame('day', $assignment->shift_code);
|
||||||
|
$this->assertSame((int) $this->unit->id, (int) $assignment->care_unit_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_roster_cancel_ends_assignment_and_allows_reassign(): void
|
||||||
|
{
|
||||||
|
$roster = app(RosterService::class);
|
||||||
|
$shifts = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id);
|
||||||
|
$day = $shifts->firstWhere('code', 'day');
|
||||||
|
$date = now()->toDateString();
|
||||||
|
|
||||||
|
$entry = $roster->assign(
|
||||||
|
$this->unit,
|
||||||
|
$day,
|
||||||
|
$this->nurseMember,
|
||||||
|
$date,
|
||||||
|
$this->owner->public_id,
|
||||||
|
$this->owner->public_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->delete(route('care.care-units.roster.destroy', [$this->unit, $entry]))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSoftDeleted('care_roster_entries', ['id' => $entry->id]);
|
||||||
|
$this->assertDatabaseHas('care_staff_assignments', [
|
||||||
|
'id' => $entry->staff_assignment_id,
|
||||||
|
'status' => 'ended',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->post(route('care.care-units.roster.store', $this->unit), [
|
||||||
|
'shift_id' => $day->id,
|
||||||
|
'member_id' => $this->nurseMember->id,
|
||||||
|
'duty_date' => $date,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(1, RosterEntry::query()->where('care_unit_id', $this->unit->id)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nursing_services_hub_and_specialty_redirect(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(app(SpecialtyModuleService::class)->isEnabled($this->organization, 'nursing_services'));
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.nursing.services'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Nursing Services')
|
||||||
|
->assertSee('Nurse Ama')
|
||||||
|
->assertSee('Medical Ward');
|
||||||
|
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.specialty.show', 'nursing_services'))
|
||||||
|
->assertRedirect(route('care.nursing.services'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unit_roster_grid_renders(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->owner)
|
||||||
|
->get(route('care.care-units.roster', $this->unit))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Week roster')
|
||||||
|
->assertSee('Day shift');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user