Separate unit placements from shift duty assignments.
Deploy Ladill Care / deploy (push) Successful in 30s
Deploy Ladill Care / deploy (push) Successful in 30s
Roster no longer writes temporary staff assignments; unit assignment UI drops shift fields and labels clarify the two workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -52,7 +52,6 @@ class NursingServicesController extends Controller
|
|||||||
'canRoster' => $permissions->can($member, 'nursing.roster.manage'),
|
'canRoster' => $permissions->can($member, 'nursing.roster.manage'),
|
||||||
'canViewRoster' => $permissions->can($member, 'nursing.roster.view'),
|
'canViewRoster' => $permissions->can($member, 'nursing.roster.view'),
|
||||||
'assignmentRoles' => config('care.staff_assignment_roles'),
|
'assignmentRoles' => config('care.staff_assignment_roles'),
|
||||||
'shiftCodes' => config('care.staff_shift_codes'),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class StaffAssignmentController extends Controller
|
|||||||
|
|
||||||
$assignments = StaffAssignment::owned($owner)
|
$assignments = StaffAssignment::owned($owner)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
|
->unitPlacements()
|
||||||
->with(['member.branch', 'department.branch', 'careUnit'])
|
->with(['member.branch', 'department.branch', 'careUnit'])
|
||||||
->orderByDesc('starts_on')
|
->orderByDesc('starts_on')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
@@ -45,7 +46,6 @@ class StaffAssignmentController extends Controller
|
|||||||
'kinds' => config('care.staff_assignment_kinds'),
|
'kinds' => config('care.staff_assignment_kinds'),
|
||||||
'statuses' => config('care.staff_assignment_statuses'),
|
'statuses' => config('care.staff_assignment_statuses'),
|
||||||
'assignmentRoles' => config('care.staff_assignment_roles'),
|
'assignmentRoles' => config('care.staff_assignment_roles'),
|
||||||
'shiftCodes' => config('care.staff_shift_codes'),
|
|
||||||
'memberLabels' => $this->memberLabels($assignments->pluck('member')->filter()),
|
'memberLabels' => $this->memberLabels($assignments->pluck('member')->filter()),
|
||||||
'heroStats' => $heroStats,
|
'heroStats' => $heroStats,
|
||||||
]);
|
]);
|
||||||
@@ -74,7 +74,7 @@ class StaffAssignmentController extends Controller
|
|||||||
|
|
||||||
AuditLogger::record($owner, 'staff_assignment.created', $organization->id, $owner, StaffAssignment::class, $assignment->id);
|
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.');
|
return redirect()->route('care.staff-assignments.index')->with('success', 'Unit assignment created.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(Request $request, StaffAssignment $staffAssignment): View
|
public function edit(Request $request, StaffAssignment $staffAssignment): View
|
||||||
@@ -100,7 +100,7 @@ class StaffAssignmentController extends Controller
|
|||||||
|
|
||||||
AuditLogger::record($owner, 'staff_assignment.updated', $organization->id, $owner, StaffAssignment::class, $staffAssignment->id);
|
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.');
|
return redirect()->route('care.staff-assignments.index')->with('success', 'Unit assignment updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, StaffAssignment $staffAssignment): RedirectResponse
|
public function destroy(Request $request, StaffAssignment $staffAssignment): RedirectResponse
|
||||||
@@ -119,7 +119,7 @@ class StaffAssignmentController extends Controller
|
|||||||
$staffAssignment->id,
|
$staffAssignment->id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()->route('care.staff-assignments.index')->with('success', 'Staff assignment removed.');
|
return redirect()->route('care.staff-assignments.index')->with('success', 'Unit assignment removed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,7 +159,6 @@ class StaffAssignmentController extends Controller
|
|||||||
'kinds' => config('care.staff_assignment_kinds'),
|
'kinds' => config('care.staff_assignment_kinds'),
|
||||||
'statuses' => config('care.staff_assignment_statuses'),
|
'statuses' => config('care.staff_assignment_statuses'),
|
||||||
'assignmentRoles' => config('care.staff_assignment_roles'),
|
'assignmentRoles' => config('care.staff_assignment_roles'),
|
||||||
'shiftCodes' => config('care.staff_shift_codes'),
|
|
||||||
'rbacRoles' => config('care.roles'),
|
'rbacRoles' => config('care.roles'),
|
||||||
'selectedMemberId' => $request->integer('member_id') ?: null,
|
'selectedMemberId' => $request->integer('member_id') ?: null,
|
||||||
];
|
];
|
||||||
@@ -181,13 +180,12 @@ class StaffAssignmentController extends Controller
|
|||||||
],
|
],
|
||||||
'kind' => ['required', 'string', Rule::in(array_keys(config('care.staff_assignment_kinds')))],
|
'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')))],
|
'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'],
|
'starts_on' => ['required', 'date'],
|
||||||
'ends_on' => ['nullable', 'date', 'after_or_equal:starts_on'],
|
'ends_on' => ['nullable', 'date', 'after_or_equal:starts_on'],
|
||||||
'status' => ['required', 'string', Rule::in(array_keys(config('care.staff_assignment_statuses')))],
|
'status' => ['required', 'string', Rule::in(array_keys(config('care.staff_assignment_statuses')))],
|
||||||
'notes' => ['nullable', 'string', 'max:2000'],
|
'notes' => ['nullable', 'string', 'max:2000'],
|
||||||
], [
|
], [
|
||||||
'care_unit_id.required' => 'Temporary assignments require a care unit.',
|
'care_unit_id.required' => 'Temporary unit assignments require a care unit.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$member = Member::owned($owner)->findOrFail($validated['member_id']);
|
$member = Member::owned($owner)->findOrFail($validated['member_id']);
|
||||||
@@ -221,7 +219,7 @@ class StaffAssignmentController extends Controller
|
|||||||
'care_unit_id' => $validated['care_unit_id'] ? (int) $validated['care_unit_id'] : null,
|
'care_unit_id' => $validated['care_unit_id'] ? (int) $validated['care_unit_id'] : null,
|
||||||
'kind' => $validated['kind'],
|
'kind' => $validated['kind'],
|
||||||
'assignment_role' => $validated['assignment_role'] ?? null,
|
'assignment_role' => $validated['assignment_role'] ?? null,
|
||||||
'shift_code' => $validated['shift_code'] ?? null,
|
'shift_code' => null,
|
||||||
'starts_on' => $validated['starts_on'],
|
'starts_on' => $validated['starts_on'],
|
||||||
'ends_on' => $validated['ends_on'] ?? null,
|
'ends_on' => $validated['ends_on'] ?? null,
|
||||||
'status' => $validated['status'],
|
'status' => $validated['status'],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Carbon\CarbonInterface;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class StaffAssignment extends Model
|
class StaffAssignment extends Model
|
||||||
@@ -58,6 +59,20 @@ class StaffAssignment extends Model
|
|||||||
return $this->belongsTo(CareUnit::class, 'care_unit_id');
|
return $this->belongsTo(CareUnit::class, 'care_unit_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy link from when duty roster auto-created unit placements.
|
||||||
|
* Shift assignments live on RosterEntry; new roster rows do not set this.
|
||||||
|
*/
|
||||||
|
public function rosterEntries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(RosterEntry::class, 'staff_assignment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeUnitPlacements(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereDoesntHave('rosterEntries');
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeEffectiveOn(Builder $query, CarbonInterface|string|null $date = null): Builder
|
public function scopeEffectiveOn(Builder $query, CarbonInterface|string|null $date = null): Builder
|
||||||
{
|
{
|
||||||
$day = $date
|
$day = $date
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class NursePerformanceService
|
|||||||
$assignedStaff = StaffAssignment::owned($ownerRef)
|
$assignedStaff = StaffAssignment::owned($ownerRef)
|
||||||
->where('care_unit_id', $unit->id)
|
->where('care_unit_id', $unit->id)
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
|
->unitPlacements()
|
||||||
->effectiveOn($to)
|
->effectiveOn($to)
|
||||||
->with('member')
|
->with('member')
|
||||||
->get();
|
->get();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class NursingServicesService
|
|||||||
return StaffAssignment::owned($ownerRef)
|
return StaffAssignment::owned($ownerRef)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
|
->unitPlacements()
|
||||||
->effectiveOn(now())
|
->effectiveOn(now())
|
||||||
->whereHas('member', fn ($q) => $q->whereIn('role', self::NURSING_ROLES))
|
->whereHas('member', fn ($q) => $q->whereIn('role', self::NURSING_ROLES))
|
||||||
->with(['member', 'careUnit.department', 'department'])
|
->with(['member', 'careUnit.department', 'department'])
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use App\Models\Member;
|
|||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\RosterEntry;
|
use App\Models\RosterEntry;
|
||||||
use App\Models\Shift;
|
use App\Models\Shift;
|
||||||
use App\Models\StaffAssignment;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
@@ -114,7 +113,6 @@ class RosterService
|
|||||||
string $ownerRef,
|
string $ownerRef,
|
||||||
?string $actorRef = null,
|
?string $actorRef = null,
|
||||||
?string $notes = null,
|
?string $notes = null,
|
||||||
bool $linkAssignment = true,
|
|
||||||
): RosterEntry {
|
): RosterEntry {
|
||||||
if ((int) $shift->organization_id !== (int) $unit->organization_id
|
if ((int) $shift->organization_id !== (int) $unit->organization_id
|
||||||
|| (int) $member->organization_id !== (int) $unit->organization_id) {
|
|| (int) $member->organization_id !== (int) $unit->organization_id) {
|
||||||
@@ -138,32 +136,13 @@ class RosterService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$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) {
|
||||||
if ($existing->trashed()) {
|
if ($existing->trashed()) {
|
||||||
$existing->restore();
|
$existing->restore();
|
||||||
}
|
}
|
||||||
$existing->update([
|
$existing->update([
|
||||||
'status' => RosterEntry::STATUS_SCHEDULED,
|
'status' => RosterEntry::STATUS_SCHEDULED,
|
||||||
'staff_assignment_id' => $assignmentId,
|
'staff_assignment_id' => null,
|
||||||
'notes' => $notes,
|
'notes' => $notes,
|
||||||
'created_by' => $actorRef,
|
'created_by' => $actorRef,
|
||||||
]);
|
]);
|
||||||
@@ -177,7 +156,7 @@ class RosterService
|
|||||||
'member_id' => $member->id,
|
'member_id' => $member->id,
|
||||||
'duty_date' => $date,
|
'duty_date' => $date,
|
||||||
'status' => RosterEntry::STATUS_SCHEDULED,
|
'status' => RosterEntry::STATUS_SCHEDULED,
|
||||||
'staff_assignment_id' => $assignmentId,
|
'staff_assignment_id' => null,
|
||||||
'notes' => $notes,
|
'notes' => $notes,
|
||||||
'created_by' => $actorRef,
|
'created_by' => $actorRef,
|
||||||
]);
|
]);
|
||||||
@@ -202,13 +181,6 @@ class RosterService
|
|||||||
|
|
||||||
$entry->update(['status' => RosterEntry::STATUS_CANCELLED]);
|
$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.
|
// Soft-delete so the unique (unit, shift, member, date) can be reused.
|
||||||
$entry->delete();
|
$entry->delete();
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -134,7 +134,8 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
| Lightweight shift codes until full roster entities (Phase 3).
|
| Shift labels for nursing notes / handovers (not unit placement).
|
||||||
|
| Duty scheduling uses care_shifts + roster entries.
|
||||||
*/
|
*/
|
||||||
'staff_shift_codes' => [
|
'staff_shift_codes' => [
|
||||||
'day' => 'Day shift',
|
'day' => 'Day shift',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<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)
|
@if ($canRoster)
|
||||||
<a href="{{ route('care.care-units.roster', $unit) }}" class="btn-secondary">Duty roster</a>
|
<a href="{{ route('care.care-units.roster', $unit) }}" class="btn-secondary">Shift assignments</a>
|
||||||
@endif
|
@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>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@if ($canManageUnit)
|
@if ($canManageUnit)
|
||||||
<a href="{{ route('care.care-units.edit', $unit) }}" class="btn-secondary">Edit unit</a>
|
<a href="{{ route('care.care-units.edit', $unit) }}" class="btn-secondary">Edit unit</a>
|
||||||
<a href="{{ route('care.staff-assignments.create', ['care_unit_id' => $unit->id]) }}" class="btn-secondary">Assign staff</a>
|
<a href="{{ route('care.staff-assignments.create', ['care_unit_id' => $unit->id]) }}" class="btn-secondary">Unit assignment</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
$assignment = $assignment ?? null;
|
$assignment = $assignment ?? null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-app-layout title="{{ $assignment ? 'Edit assignment' : 'Add assignment' }}">
|
<x-app-layout title="{{ $assignment ? 'Edit unit assignment' : 'Add unit assignment' }}">
|
||||||
<div class="mx-auto max-w-xl">
|
<div class="mx-auto max-w-xl">
|
||||||
<h1 class="text-xl font-semibold text-slate-900">{{ $assignment ? 'Edit staff assignment' : 'Add staff assignment' }}</h1>
|
<h1 class="text-xl font-semibold text-slate-900">{{ $assignment ? 'Edit unit assignment' : 'Add unit assignment' }}</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">RBAC role stays on the team member. This record is their unit placement over a date range.</p>
|
<p class="mt-1 text-sm text-slate-500">Home or float placement on a unit. Day/evening/night duty is scheduled separately on the shift roster.</p>
|
||||||
|
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="{{ $assignment ? route('care.staff-assignments.update', $assignment) : route('care.staff-assignments.store') }}"
|
action="{{ $assignment ? route('care.staff-assignments.update', $assignment) : route('care.staff-assignments.store') }}"
|
||||||
@@ -63,7 +63,6 @@
|
|||||||
@error('care_unit_id')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
@error('care_unit_id')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium">Post / grade</label>
|
<label class="block text-sm font-medium">Post / grade</label>
|
||||||
<select name="assignment_role" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="assignment_role" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
@@ -73,16 +72,6 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium">Shift</label>
|
|
||||||
<select name="shift_code" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
|
||||||
<option value="">—</option>
|
|
||||||
@foreach ($shiftCodes as $value => $label)
|
|
||||||
<option value="{{ $value }}" @selected(old('shift_code', $assignment?->shift_code) === $value)>{{ $label }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -109,13 +98,13 @@
|
|||||||
<textarea name="notes" rows="2" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('notes', $assignment?->notes) }}</textarea>
|
<textarea name="notes" rows="2" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('notes', $assignment?->notes) }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-primary w-full">{{ $assignment ? 'Save changes' : 'Create assignment' }}</button>
|
<button type="submit" class="btn-primary w-full">{{ $assignment ? 'Save changes' : 'Create unit assignment' }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if ($assignment)
|
@if ($assignment)
|
||||||
<form method="POST" action="{{ route('care.staff-assignments.destroy', $assignment) }}" class="mt-4" onsubmit="return confirm('Remove this assignment?')">
|
<form method="POST" action="{{ route('care.staff-assignments.destroy', $assignment) }}" class="mt-4" onsubmit="return confirm('Remove this unit assignment?')">
|
||||||
@csrf @method('DELETE')
|
@csrf @method('DELETE')
|
||||||
<button type="submit" class="text-sm font-medium text-red-600 hover:text-red-800">Remove assignment</button>
|
<button type="submit" class="text-sm font-medium text-red-600 hover:text-red-800">Remove unit assignment</button>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<x-app-layout title="Staff assignments">
|
<x-app-layout title="Unit assignments">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<x-care.page-hero
|
<x-care.page-hero
|
||||||
badge="Primary · Temporary · Specialty"
|
badge="Primary · Temporary · Specialty"
|
||||||
title="Staff assignments"
|
title="Unit assignments"
|
||||||
description="Dated employment placements — department, care unit, post, and shift. Temporary assignments cover float nurses without making ward ownership permanent."
|
description="Where staff belong — department, care unit, and post over a date range. Use the duty roster for day/evening/night shift assignments."
|
||||||
:stats="[
|
:stats="[
|
||||||
['value' => number_format($heroStats['total']), 'label' => 'Assignments'],
|
['value' => number_format($heroStats['total']), 'label' => 'Assignments'],
|
||||||
['value' => number_format($heroStats['active']), 'label' => 'Effective today'],
|
['value' => number_format($heroStats['active']), 'label' => 'Effective today'],
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
['value' => number_format($heroStats['temporary']), 'label' => 'Temporary'],
|
['value' => number_format($heroStats['temporary']), 'label' => 'Temporary'],
|
||||||
]">
|
]">
|
||||||
<x-slot name="actions">
|
<x-slot name="actions">
|
||||||
<a href="{{ route('care.staff-assignments.create') }}" class="btn-primary">Add assignment</a>
|
<a href="{{ route('care.staff-assignments.create') }}" class="btn-primary">Add unit assignment</a>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</x-care.page-hero>
|
</x-care.page-hero>
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
<th class="px-4 py-3">Kind</th>
|
<th class="px-4 py-3">Kind</th>
|
||||||
<th class="px-4 py-3">Post</th>
|
<th class="px-4 py-3">Post</th>
|
||||||
<th class="px-4 py-3">Unit / Dept</th>
|
<th class="px-4 py-3">Unit / Dept</th>
|
||||||
<th class="px-4 py-3">Shift</th>
|
|
||||||
<th class="px-4 py-3">Dates</th>
|
<th class="px-4 py-3">Dates</th>
|
||||||
<th class="px-4 py-3">Status</th>
|
<th class="px-4 py-3">Status</th>
|
||||||
<th class="px-4 py-3"></th>
|
<th class="px-4 py-3"></th>
|
||||||
@@ -41,7 +40,6 @@
|
|||||||
<div class="text-xs text-slate-400">{{ $assignment->department->name }}</div>
|
<div class="text-xs text-slate-400">{{ $assignment->department->name }}</div>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">{{ $shiftCodes[$assignment->shift_code] ?? ($assignment->shift_code ?: '—') }}</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
{{ $assignment->starts_on->format('Y-m-d') }}
|
{{ $assignment->starts_on->format('Y-m-d') }}
|
||||||
→
|
→
|
||||||
@@ -58,7 +56,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr><td colspan="8" class="px-4 py-6 text-center text-slate-500">No staff assignments yet.</td></tr>
|
<tr><td colspan="7" class="px-4 py-6 text-center text-slate-500">No unit assignments yet.</td></tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<span class="text-slate-300">/</span>
|
<span class="text-slate-300">/</span>
|
||||||
Duty roster
|
Duty roster
|
||||||
</p>
|
</p>
|
||||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Week roster</h1>
|
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Shift assignments</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
{{ $weekStart->format('d M Y') }} – {{ $weekStart->copy()->addDays(6)->format('d M Y') }}
|
{{ $weekStart->format('d M Y') }} – {{ $weekStart->copy()->addDays(6)->format('d M Y') }}
|
||||||
· {{ $unit->department?->name }}
|
· {{ $unit->department?->name }}
|
||||||
@@ -80,8 +80,8 @@
|
|||||||
|
|
||||||
@if ($canManage)
|
@if ($canManage)
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
<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>
|
<h2 class="text-base font-semibold text-slate-900">Add shift assignment</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">Creates a temporary staff assignment for that duty date.</p>
|
<p class="mt-1 text-sm text-slate-500">Schedules who works this unit on a specific date and shift. Does not change their unit placement.</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">
|
<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
|
@csrf
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="{{ route('care.shifts.index') }}" class="btn-secondary">Shift templates</a>
|
<a href="{{ route('care.shifts.index') }}" class="btn-secondary">Shift templates</a>
|
||||||
@endif
|
@endif
|
||||||
<a href="{{ route('care.care-units.index') }}" class="btn-secondary">Care units</a>
|
<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>
|
<a href="{{ route('care.staff-assignments.index') }}" class="btn-secondary">Unit assignments</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 lg:col-span-2">
|
<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>
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Today’s shift assignments</h2>
|
||||||
<div class="mt-4 overflow-x-auto">
|
<div class="mt-4 overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm">
|
||||||
<thead class="text-left text-xs uppercase text-slate-500">
|
<thead class="text-left text-xs uppercase text-slate-500">
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<td class="py-2.5">{{ $nurseLabels[$entry->member_id] ?? ($entry->member?->user_ref ?? '—') }}</td>
|
<td class="py-2.5">{{ $nurseLabels[$entry->member_id] ?? ($entry->member?->user_ref ?? '—') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr><td colspan="3" class="py-6 text-slate-500">No duty roster entries for today.</td></tr>
|
<tr><td colspan="3" class="py-6 text-slate-500">No shift assignments for today.</td></tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -73,8 +73,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
<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>
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Unit assignments</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">Dated staff assignments effective today (primary, temporary, specialty).</p>
|
<p class="mt-1 text-sm text-slate-500">Home and float placements effective today (primary, temporary, specialty) — separate from shift duty.</p>
|
||||||
<div class="mt-4 overflow-x-auto">
|
<div class="mt-4 overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm">
|
||||||
<thead class="text-left text-xs uppercase text-slate-500">
|
<thead class="text-left text-xs uppercase text-slate-500">
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr><td colspan="4" class="py-6 text-slate-500">No active nursing allocations today.</td></tr>
|
<tr><td colspan="4" class="py-6 text-slate-500">No active unit assignments today.</td></tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
<p class="mt-0.5 text-xs text-slate-500">{{ $unit->department?->name }} · {{ str_replace('_', ' ', $unit->kind) }}</p>
|
<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">
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
@if ($canViewRoster)
|
@if ($canViewRoster)
|
||||||
<a href="{{ route('care.care-units.roster', $unit) }}" class="text-indigo-600 hover:text-indigo-800">Roster</a>
|
<a href="{{ route('care.care-units.roster', $unit) }}" class="text-indigo-600 hover:text-indigo-800">Shifts</a>
|
||||||
@endif
|
@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.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.notes', $unit) }}" class="text-slate-600 hover:text-slate-900">Notes</a>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
'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" />'];
|
'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' => 'Unit 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" />'];
|
||||||
}
|
}
|
||||||
if ($permissions->can($member, 'admin.practitioners.view')) {
|
if ($permissions->can($member, 'admin.practitioners.view')) {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class CareRosterAndNursingServicesTest extends TestCase
|
|||||||
$this->assertSame(3, Shift::query()->where('organization_id', $this->organization->id)->count());
|
$this->assertSame(3, Shift::query()->where('organization_id', $this->organization->id)->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_roster_assign_links_temporary_staff_assignment(): void
|
public function test_roster_assign_does_not_create_unit_assignment(): void
|
||||||
{
|
{
|
||||||
$roster = app(RosterService::class);
|
$roster = app(RosterService::class);
|
||||||
$shifts = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id);
|
$shifts = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id);
|
||||||
@@ -128,15 +128,11 @@ class CareRosterAndNursingServicesTest extends TestCase
|
|||||||
$entry = RosterEntry::query()->first();
|
$entry = RosterEntry::query()->first();
|
||||||
$this->assertNotNull($entry);
|
$this->assertNotNull($entry);
|
||||||
$this->assertSame(RosterEntry::STATUS_SCHEDULED, $entry->status);
|
$this->assertSame(RosterEntry::STATUS_SCHEDULED, $entry->status);
|
||||||
$this->assertNotNull($entry->staff_assignment_id);
|
$this->assertNull($entry->staff_assignment_id);
|
||||||
|
$this->assertSame(0, StaffAssignment::query()->count());
|
||||||
$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
|
public function test_roster_cancel_allows_reassign(): void
|
||||||
{
|
{
|
||||||
$roster = app(RosterService::class);
|
$roster = app(RosterService::class);
|
||||||
$shifts = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id);
|
$shifts = $roster->ensureDefaultShifts($this->organization, $this->owner->public_id);
|
||||||
@@ -157,10 +153,7 @@ class CareRosterAndNursingServicesTest extends TestCase
|
|||||||
->assertRedirect();
|
->assertRedirect();
|
||||||
|
|
||||||
$this->assertSoftDeleted('care_roster_entries', ['id' => $entry->id]);
|
$this->assertSoftDeleted('care_roster_entries', ['id' => $entry->id]);
|
||||||
$this->assertDatabaseHas('care_staff_assignments', [
|
$this->assertSame(0, StaffAssignment::query()->count());
|
||||||
'id' => $entry->staff_assignment_id,
|
|
||||||
'status' => 'ended',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($this->owner)
|
$this->actingAs($this->owner)
|
||||||
->post(route('care.care-units.roster.store', $this->unit), [
|
->post(route('care.care-units.roster.store', $this->unit), [
|
||||||
@@ -194,7 +187,7 @@ class CareRosterAndNursingServicesTest extends TestCase
|
|||||||
$this->actingAs($this->owner)
|
$this->actingAs($this->owner)
|
||||||
->get(route('care.care-units.roster', $this->unit))
|
->get(route('care.care-units.roster', $this->unit))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Week roster')
|
->assertSee('Shift assignments')
|
||||||
->assertSee('Day shift');
|
->assertSee('Day shift');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,6 @@ class CareStaffManagementTest extends TestCase
|
|||||||
'care_unit_id' => $floatPool->id,
|
'care_unit_id' => $floatPool->id,
|
||||||
'kind' => 'primary',
|
'kind' => 'primary',
|
||||||
'assignment_role' => 'float_nurse',
|
'assignment_role' => 'float_nurse',
|
||||||
'shift_code' => 'rotating',
|
|
||||||
'starts_on' => now()->toDateString(),
|
'starts_on' => now()->toDateString(),
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
])
|
])
|
||||||
@@ -189,7 +188,6 @@ class CareStaffManagementTest extends TestCase
|
|||||||
'care_unit_id' => $labour->id,
|
'care_unit_id' => $labour->id,
|
||||||
'kind' => 'temporary',
|
'kind' => 'temporary',
|
||||||
'assignment_role' => 'midwife',
|
'assignment_role' => 'midwife',
|
||||||
'shift_code' => 'night',
|
|
||||||
'starts_on' => now()->toDateString(),
|
'starts_on' => now()->toDateString(),
|
||||||
'ends_on' => now()->toDateString(),
|
'ends_on' => now()->toDateString(),
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
@@ -209,8 +207,10 @@ class CareStaffManagementTest extends TestCase
|
|||||||
$this->actingAs($this->owner)
|
$this->actingAs($this->owner)
|
||||||
->get(route('care.staff-assignments.index'))
|
->get(route('care.staff-assignments.index'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
->assertSee('Unit assignments')
|
||||||
->assertSee('Float Pool')
|
->assertSee('Float Pool')
|
||||||
->assertSee('Labour Ward');
|
->assertSee('Labour Ward')
|
||||||
|
->assertDontSee('>Shift<', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_temporary_assignment_requires_unit(): void
|
public function test_temporary_assignment_requires_unit(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user