Nest Care team under settings with Frontdesk-style UI.
Deploy Ladill Care / deploy (push) Successful in 46s

Move team routes to /settings/team, restyle list and invite pages with settings layout and breadcrumbs, and keep legacy /members redirects.
This commit is contained in:
isaacclad
2026-07-16 09:08:24 +00:00
parent 59ddf92642
commit b092f9a934
5 changed files with 160 additions and 123 deletions
@@ -29,12 +29,14 @@ class MemberController extends Controller
->orderBy('created_at')
->get();
$adminRoles = ['super_admin', 'hospital_admin'];
$clinicalRoles = ['doctor', 'nurse', 'lab_technician', 'pharmacist'];
$heroStats = [
'total' => $members->count(),
'admins' => $members->whereIn('role', $adminRoles)->count(),
'clinical' => $members->whereIn('role', $clinicalRoles)->count(),
'branches' => $members->whereNotNull('branch_id')->pluck('branch_id')->unique()->count(),
'branch_scoped' => $members->whereNotNull('branch_id')->count(),
];
return view('care.admin.members.index', [
@@ -1,78 +1,88 @@
<x-app-layout title="Add member">
<div class="mx-auto max-w-lg">
<h1 class="text-xl font-semibold text-slate-900">Invite team member</h1>
<p class="mt-1 text-sm text-slate-500">They get access only to Ladill Care (plus Mail if they already have a Ladill mailbox).</p>
<x-settings.page title="Invite team member" description="They get access only to Ladill Care (plus Mail if they already have a Ladill mailbox).">
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm">
<a href="{{ route('care.settings') }}" class="text-slate-500 hover:text-slate-800">Settings</a>
<span class="text-slate-300">/</span>
<a href="{{ route('care.members.index') }}" class="text-slate-500 hover:text-slate-800">Team</a>
<span class="text-slate-300">/</span>
<span class="font-medium text-slate-900">Invite</span>
</div>
<form method="POST" action="{{ route('care.members.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6" x-data="{ role: '{{ old('role', 'doctor') }}' }">
@csrf
<x-settings.card title="Invitation">
<form method="POST" action="{{ route('care.members.store') }}" class="space-y-4" x-data="{ role: '{{ old('role', 'doctor') }}' }">
@csrf
@if (! empty($mailboxOptions))
<div>
<label class="block text-sm font-medium text-slate-700">From a Ladill mailbox</label>
<select x-on:change="if ($event.target.value) { $refs.email.value = $event.target.value }"
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Choose a mailbox…</option>
@foreach ($mailboxOptions as $address)
<option value="{{ $address }}">{{ $address }}</option>
@endforeach
</select>
<p class="mt-1 text-xs text-slate-500">Or type any email below.</p>
</div>
@endif
@if (! empty($mailboxOptions))
<div>
<label class="block text-sm font-medium text-slate-700">From a Ladill mailbox</label>
<select x-on:change="if ($event.target.value) { $refs.email.value = $event.target.value }"
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Choose a mailbox…</option>
@foreach ($mailboxOptions as $address)
<option value="{{ $address }}">{{ $address }}</option>
<label class="block text-sm font-medium text-slate-700">Email address</label>
<input type="email" name="email" value="{{ old('email') }}" required x-ref="email"
class="mt-1 w-full rounded-lg border-slate-300 text-sm"
placeholder="colleague@company.com">
<p class="mt-1 text-xs text-slate-500">They will receive an email to accept and join your Care organization.</p>
@error('email')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Role</label>
<select name="role" x-model="role" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($roles as $value => $label)
<option value="{{ $value }}" @selected(old('role', 'doctor') === $value)>{{ $label }}</option>
@endforeach
</select>
<p class="mt-1 text-xs text-slate-500">Or type any email below.</p>
</div>
@endif
<div>
<label class="block text-sm font-medium text-slate-700">Email address</label>
<input type="email" name="email" value="{{ old('email') }}" required x-ref="email"
class="mt-1 w-full rounded-lg border-slate-300 text-sm"
placeholder="colleague@company.com">
<p class="mt-1 text-xs text-slate-500">They will receive an email to accept and join your Care organization.</p>
@error('email')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch (optional)</label>
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">All branches</option>
@foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Role</label>
<select name="role" x-model="role" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($roles as $value => $label)
<option value="{{ $value }}" @selected(old('role', 'doctor') === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch (optional)</label>
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">All branches</option>
@foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option>
@endforeach
</select>
</div>
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4" x-show="role === 'doctor'" x-cloak>
<label class="flex items-start gap-2 text-sm">
<input type="checkbox" name="create_practitioner" value="1" class="mt-0.5" @checked(old('create_practitioner', true))>
<span>
<span class="font-medium text-slate-800">Also add as practitioner</span>
<span class="mt-0.5 block text-xs text-slate-500">So they appear on appointments and the clinical queue.</span>
</span>
</label>
<div class="mt-3 space-y-3">
<div>
<label class="block text-xs font-medium text-slate-600">Display name</label>
<input type="text" name="practitioner_name" value="{{ old('practitioner_name') }}"
class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="Dr. Ama Mensah">
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Specialty (optional)</label>
<input type="text" name="specialty" value="{{ old('specialty') }}"
class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="General practice">
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4" x-show="role === 'doctor'" x-cloak>
<label class="flex items-start gap-2 text-sm">
<input type="checkbox" name="create_practitioner" value="1" class="mt-0.5" @checked(old('create_practitioner', true))>
<span>
<span class="font-medium text-slate-800">Also add as practitioner</span>
<span class="mt-0.5 block text-xs text-slate-500">So they appear on appointments and the clinical queue.</span>
</span>
</label>
<div class="mt-3 space-y-3">
<div>
<label class="block text-xs font-medium text-slate-600">Display name</label>
<input type="text" name="practitioner_name" value="{{ old('practitioner_name') }}"
class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="Dr. Ama Mensah">
</div>
<div>
<label class="block text-xs font-medium text-slate-600">Specialty (optional)</label>
<input type="text" name="specialty" value="{{ old('specialty') }}"
class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="General practice">
</div>
</div>
</div>
</div>
<button type="submit" class="btn-primary">Send invitation</button>
</form>
</div>
<div class="flex justify-end gap-3">
<a href="{{ route('care.members.index') }}" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
<button type="submit" class="btn-primary">Send invitation</button>
</div>
</form>
</x-settings.card>
</x-settings.page>
</x-app-layout>
@@ -1,55 +1,75 @@
<x-app-layout title="Team members">
<div class="space-y-6">
<x-care.page-hero
badge="Roles · Access · Branch assignment"
title="Team"
description="Manage who can access Care, their roles, and which branch each member belongs to."
:stats="[
['value' => number_format($heroStats['total']), 'label' => 'Members'],
['value' => number_format($heroStats['clinical']), 'label' => 'Clinical staff'],
['value' => number_format($heroStats['branches']), 'label' => 'Branches staffed'],
]">
<x-slot name="actions">
<a href="{{ route('care.members.create') }}" class="btn-primary">Add member</a>
</x-slot>
</x-care.page-hero>
@php
$member = auth()->user()
? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user())
: null;
$permissions = app(\App\Services\Care\CarePermissions::class);
$canManageMembers = $permissions->can($member, 'admin.members.manage');
$canViewBranches = $permissions->can($member, 'admin.branches.view');
@endphp
<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">Member</th><th class="px-4 py-3">Role</th><th class="px-4 py-3">Branch</th><th class="px-4 py-3"></th></tr>
</thead>
<tbody class="divide-y divide-slate-50">
@foreach ($members as $member)
@php
$display = str_contains($member->user_ref, '@')
? $member->user_ref
: (\App\Models\User::where('public_id', $member->user_ref)->value('email') ?? $member->user_ref);
@endphp
<tr>
<td class="px-4 py-3 text-sm">{{ $display }}</td>
<td class="px-4 py-3">{{ $roles[$member->role] ?? $member->role }}</td>
<td class="px-4 py-3">{{ $member->branch?->name ?? 'All branches' }}</td>
<td class="px-4 py-3 text-right">
@if ($member->user_ref !== auth()->user()->public_id)
<x-confirm-dialog
:name="'remove-member-'.$member->id"
title="Remove this member?"
message="They will lose access to this Care organization."
:action="route('care.members.destroy', $member)"
method="DELETE"
confirm-label="Remove member"
>
<x-slot:trigger>
<x-btn type="button" variant="danger" size="sm">Remove</x-btn>
</x-slot:trigger>
</x-confirm-dialog>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
<x-app-layout title="Team">
<x-settings.page title="Team" description="Manage who can access Care, their roles, and which branch each member belongs to.">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm">
<a href="{{ route('care.settings') }}" class="text-slate-500 hover:text-slate-800">Settings</a>
<span class="text-slate-300">/</span>
<span class="font-medium text-slate-900">Team</span>
@if ($canViewBranches)
<a href="{{ route('care.branches.index') }}" class="ml-3 text-indigo-600 hover:text-indigo-800">Branches</a>
@endif
</div>
@if ($canManageMembers)
<a href="{{ route('care.members.create') }}" class="btn-primary">Add member</a>
@endif
</div>
</div>
<x-settings.card title="Team members" description="{{ number_format($heroStats['total']) }} members · {{ number_format($heroStats['admins']) }} administrators · {{ number_format($heroStats['clinical']) }} clinical · {{ number_format($heroStats['branch_scoped']) }} branch-scoped">
@if ($members->isEmpty())
<p class="text-sm text-slate-500">No team members yet.</p>
@else
<div class="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">Member</th>
<th class="pb-2 pr-4">Role</th>
<th class="pb-2 pr-4">Branch</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@foreach ($members as $teamMember)
@php
$display = str_contains($teamMember->user_ref, '@')
? $teamMember->user_ref
: (\App\Models\User::where('public_id', $teamMember->user_ref)->value('email') ?? $teamMember->user_ref);
@endphp
<tr>
<td class="py-3 pr-4">{{ $display }}</td>
<td class="py-3 pr-4">{{ $roles[$teamMember->role] ?? $teamMember->role }}</td>
<td class="py-3 pr-4">{{ $teamMember->branch?->name ?? 'All branches' }}</td>
<td class="py-3 text-right">
@if ($canManageMembers && $teamMember->user_ref !== auth()->user()->public_id)
<x-confirm-dialog
:name="'remove-member-'.$teamMember->id"
title="Remove this member?"
message="They will lose access to this Care organization."
:action="route('care.members.destroy', $teamMember)"
method="DELETE"
confirm-label="Remove member"
>
<x-slot:trigger>
<button type="button" class="text-sm font-medium text-red-600 hover:text-red-800">Remove</button>
</x-slot:trigger>
</x-confirm-dialog>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</x-settings.card>
</x-settings.page>
</x-app-layout>
+7 -4
View File
@@ -178,10 +178,13 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::put('/practitioners/{practitioner}', [PractitionerController::class, 'update'])->name('care.practitioners.update');
Route::delete('/practitioners/{practitioner}', [PractitionerController::class, 'destroy'])->name('care.practitioners.destroy');
Route::get('/members', [MemberController::class, 'index'])->name('care.members.index');
Route::get('/members/create', [MemberController::class, 'create'])->name('care.members.create');
Route::post('/members', [MemberController::class, 'store'])->name('care.members.store');
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('care.members.destroy');
Route::get('/settings/team', [MemberController::class, 'index'])->name('care.members.index');
Route::get('/settings/team/create', [MemberController::class, 'create'])->name('care.members.create');
Route::post('/settings/team', [MemberController::class, 'store'])->name('care.members.store');
Route::delete('/settings/team/{member}', [MemberController::class, 'destroy'])->name('care.members.destroy');
Route::redirect('/members', '/settings/team');
Route::redirect('/members/create', '/settings/team/create');
Route::get('/audit-logs', [AuditLogController::class, 'index'])->name('care.audit.index');
Route::get('/audit-logs/export', [AuditLogController::class, 'export'])->name('care.audit.export');
+3 -1
View File
@@ -139,7 +139,9 @@ class CareWebTest extends TestCase
$this->actingAs($this->user)
->get(route('care.members.index'))
->assertOk()
->assertSee('test-user-001');
->assertSee('Team members')
->assertSee('test@example.com')
->assertSee('Settings');
}
public function test_audit_log_index_loads(): void