Lock nursing note shift from duty roster or clock.
Deploy Ladill Care / deploy (push) Successful in 46s

Nurses can no longer pick a shift on the form; the server sets it from today's unit roster, falling back to the current clock window.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 18:36:52 +00:00
co-authored by Cursor
parent e539438071
commit 9443465c5c
4 changed files with 106 additions and 11 deletions
@@ -10,6 +10,7 @@ use App\Models\WardHandover;
use App\Services\Care\CarePermissions;
use App\Services\Care\CareUnitCensusService;
use App\Services\Care\NursingDocumentationService;
use App\Services\Care\RosterService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@@ -19,7 +20,7 @@ class NursingDocumentationController extends Controller
{
use ScopesToAccount;
public function unitNotes(Request $request, CareUnit $careUnit, NursingDocumentationService $docs, CareUnitCensusService $census): View
public function unitNotes(Request $request, CareUnit $careUnit, NursingDocumentationService $docs, CareUnitCensusService $census, RosterService $roster): View
{
$this->authorizeAbility($request, 'nursing.notes.view');
$this->authorizeOwner($request, $careUnit);
@@ -31,18 +32,25 @@ class NursingDocumentationController extends Controller
$selectedVisitId = 0;
}
$lockedShiftCode = $roster->dutyShiftCodeForMember(
$careUnit,
$this->member($request),
$this->ownerRef($request),
);
return view('care.nursing.notes', [
'unit' => $careUnit->load('department.branch'),
'notes' => $docs->notesForUnit($careUnit, $this->ownerRef($request)),
'placedVisits' => $placedVisits,
'selectedVisitId' => $selectedVisitId ?: (int) old('visit_id', 0),
'lockedShiftCode' => $lockedShiftCode,
'canWrite' => app(CarePermissions::class)->can($this->member($request), 'nursing.notes.manage'),
'noteTypes' => config('care.nursing_note_types'),
'shiftCodes' => config('care.staff_shift_codes'),
]);
}
public function storeNote(Request $request, CareUnit $careUnit, NursingDocumentationService $docs, CareUnitCensusService $census): RedirectResponse
public function storeNote(Request $request, CareUnit $careUnit, NursingDocumentationService $docs, CareUnitCensusService $census, RosterService $roster): RedirectResponse
{
$this->authorizeAbility($request, 'nursing.notes.manage');
$this->authorizeOwner($request, $careUnit);
@@ -50,7 +58,6 @@ class NursingDocumentationController extends Controller
$validated = $request->validate([
'visit_id' => ['required', 'integer', 'exists:care_visits,id'],
'note_type' => ['required', 'string', Rule::in(array_keys(config('care.nursing_note_types')))],
'shift_code' => ['nullable', 'string', Rule::in(array_keys(config('care.staff_shift_codes')))],
'body' => ['required', 'string', 'max:5000'],
]);
@@ -61,13 +68,19 @@ class NursingDocumentationController extends Controller
$visit = $census->ensurePlaced($visit, $careUnit, $this->ownerRef($request), $this->actorRef($request));
$shiftCode = $roster->dutyShiftCodeForMember(
$careUnit,
$this->member($request),
$this->ownerRef($request),
);
$docs->addNote(
$visit,
$this->ownerRef($request),
$this->actorRef($request),
$validated['body'],
$validated['note_type'],
$validated['shift_code'] ?? null,
$shiftCode,
$careUnit->id,
);
+72
View File
@@ -271,4 +271,76 @@ class RosterService
return $labels;
}
/**
* Shift code for nursing notes: today's roster on this unit, else clock-based default.
*/
public function dutyShiftCodeForMember(
CareUnit $unit,
?Member $member,
string $ownerRef,
?CarbonInterface $at = null,
): ?string {
$at = Carbon::parse($at ?? now());
$allowed = array_keys(config('care.staff_shift_codes', []));
if ($member) {
$entry = RosterEntry::owned($ownerRef)
->where('care_unit_id', $unit->id)
->where('member_id', $member->id)
->whereDate('duty_date', $at->toDateString())
->where('status', '!=', RosterEntry::STATUS_CANCELLED)
->with('shift')
->orderBy('shift_id')
->first();
$code = $entry?->shift?->code;
if ($code && in_array($code, $allowed, true)) {
return $code;
}
}
$organization = $unit->organization
?? Organization::query()->find($unit->organization_id);
if (! $organization) {
return null;
}
$shifts = $this->ensureDefaultShifts($organization, $ownerRef);
foreach ($shifts as $shift) {
if (! in_array($shift->code, $allowed, true)) {
continue;
}
if ($this->clockFallsInShift($at, (string) $shift->start_time, (string) $shift->end_time)) {
return $shift->code;
}
}
return null;
}
protected function clockFallsInShift(CarbonInterface $at, string $startTime, string $endTime): bool
{
$minutes = ((int) $at->format('H') * 60) + (int) $at->format('i');
$start = $this->timeToMinutes($startTime);
$end = $this->timeToMinutes($endTime);
if ($start === $end) {
return true;
}
// Overnight (e.g. 23:0007:00)
if ($start > $end) {
return $minutes >= $start || $minutes < $end;
}
return $minutes >= $start && $minutes < $end;
}
protected function timeToMinutes(string $time): int
{
$parts = explode(':', substr($time, 0, 5));
return ((int) ($parts[0] ?? 0) * 60) + (int) ($parts[1] ?? 0);
}
}
+7 -6
View File
@@ -38,12 +38,13 @@
<div class="grid gap-4 sm:grid-cols-3">
<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 }}">{{ $label }}</option>
@endforeach
</select>
@php
$shiftCode = $lockedShiftCode ?? null;
$shiftLabel = $shiftCode
? ($shiftCodes[$shiftCode] ?? $shiftCode)
: '—';
@endphp
<input type="text" value="{{ $shiftLabel }}" disabled class="mt-1 w-full rounded-lg border-slate-200 bg-slate-50 text-sm text-slate-700">
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium">Note</label>
+10 -1
View File
@@ -195,11 +195,19 @@ class CareMarAndNursingTest extends TestCase
public function test_nursing_note_and_handover(): void
{
$this->travelTo(now()->setTime(16, 30));
$this->actingAs($this->owner)
->get(route('care.care-units.notes', $this->unit))
->assertOk()
->assertSee('Evening shift')
->assertDontSee('name="shift_code"', false);
$this->actingAs($this->owner)
->post(route('care.care-units.notes.store', $this->unit), [
'visit_id' => $this->visit->id,
'note_type' => 'progress',
'shift_code' => 'day',
'shift_code' => 'night', // ignored — duty/clock sets shift
'body' => 'Vitals stable; pain controlled.',
])
->assertRedirect(route('care.care-units.notes', [$this->unit, 'visit' => $this->visit->id]));
@@ -207,6 +215,7 @@ class CareMarAndNursingTest extends TestCase
$this->assertDatabaseHas('care_nursing_notes', [
'visit_id' => $this->visit->id,
'body' => 'Vitals stable; pain controlled.',
'shift_code' => 'evening',
]);
$this->actingAs($this->owner)