Add practitioners admin and staff-scoped Care UX.
Deploy Ladill Care / deploy (push) Successful in 43s
Deploy Ladill Care / deploy (push) Successful in 43s
Hospital admins can manage assignable doctors and invite team members from Ladill mailboxes; invited staff only see Care tools they need. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,13 +1,28 @@
|
||||
<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>
|
||||
|
||||
<form method="POST" action="{{ route('care.members.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<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
|
||||
|
||||
@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
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Email address</label>
|
||||
<input type="email" name="email" value="{{ old('email') }}" required
|
||||
<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>
|
||||
@@ -18,9 +33,9 @@
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Role</label>
|
||||
<select name="role" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<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') === $value)>{{ $label }}</option>
|
||||
<option value="{{ $value }}" @selected(old('role', 'doctor') === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@@ -35,6 +50,28 @@
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Send invitation</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<x-app-layout title="Add practitioner">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Add practitioner</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Creates a clinical listing for bookings. Invite them under Team if they also need to sign in.</p>
|
||||
|
||||
<form method="POST" action="{{ route('care.practitioners.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Name</label>
|
||||
<input type="text" name="name" value="{{ old('name') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="Dr. Ama Mensah">
|
||||
@error('name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">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>
|
||||
<label class="block text-sm font-medium">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">Department (optional)</label>
|
||||
<select name="department_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">None</option>
|
||||
@foreach ($departments as $department)
|
||||
<option value="{{ $department->id }}" @selected(old('department_id') == $department->id)>{{ $department->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Link team member (optional)</label>
|
||||
<select name="member_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">No login linked</option>
|
||||
@foreach ($members as $member)
|
||||
<option value="{{ $member->id }}" @selected(old('member_id') == $member->id)>
|
||||
{{ $member->user_ref }} · {{ config('care.roles.'.$member->role, $member->role) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Link after inviting them under Administration → Team.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,59 @@
|
||||
<x-app-layout title="Edit practitioner">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Edit practitioner</h1>
|
||||
|
||||
<form method="POST" action="{{ route('care.practitioners.update', $practitioner) }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Name</label>
|
||||
<input type="text" name="name" value="{{ old('name', $practitioner->name) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Specialty (optional)</label>
|
||||
<input type="text" name="specialty" value="{{ old('specialty', $practitioner->specialty) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">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', $practitioner->branch_id) == $branch->id)>{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Department (optional)</label>
|
||||
<select name="department_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">None</option>
|
||||
@foreach ($departments as $department)
|
||||
<option value="{{ $department->id }}" @selected(old('department_id', $practitioner->department_id) == $department->id)>{{ $department->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Link team member (optional)</label>
|
||||
<select name="member_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">No login linked</option>
|
||||
@foreach ($members as $member)
|
||||
<option value="{{ $member->id }}" @selected(old('member_id', $practitioner->member_id) == $member->id)>
|
||||
{{ $member->user_ref }} · {{ config('care.roles.'.$member->role, $member->role) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $practitioner->is_active))>
|
||||
Active
|
||||
</label>
|
||||
<button type="submit" class="btn-primary w-full">Save changes</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('care.practitioners.destroy', $practitioner) }}" class="mt-4"
|
||||
onsubmit="return confirm('Remove this practitioner from Care? Existing appointments keep their history.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn-secondary w-full text-rose-700">Remove practitioner</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,60 @@
|
||||
<x-app-layout title="Practitioners">
|
||||
<div class="space-y-6">
|
||||
<x-care.page-hero
|
||||
badge="Doctors · Clinicians"
|
||||
title="Practitioners"
|
||||
description="People who can be assigned on appointments, the clinical queue, consultations, and prescriptions. Link a team member so they can also sign in."
|
||||
:stats="[
|
||||
['value' => number_format($heroStats['total']), 'label' => 'Practitioners'],
|
||||
['value' => number_format($heroStats['active']), 'label' => 'Active'],
|
||||
['value' => number_format($heroStats['linked']), 'label' => 'Linked to team'],
|
||||
]">
|
||||
<x-slot name="actions">
|
||||
<a href="{{ route('care.practitioners.create') }}" class="btn-primary">Add practitioner</a>
|
||||
</x-slot>
|
||||
</x-care.page-hero>
|
||||
|
||||
@if (session('success'))
|
||||
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
@endif
|
||||
|
||||
<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">Name</th>
|
||||
<th class="px-4 py-3">Specialty</th>
|
||||
<th class="px-4 py-3">Branch</th>
|
||||
<th class="px-4 py-3">Team login</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">
|
||||
@forelse ($practitioners as $practitioner)
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">{{ $practitioner->name }}</td>
|
||||
<td class="px-4 py-3">{{ $practitioner->specialty ?: '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $practitioner->branch?->name ?? 'All branches' }}</td>
|
||||
<td class="px-4 py-3">{{ $practitioner->member?->user_ref ?? '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $practitioner->is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600' }}">
|
||||
{{ $practitioner->is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ route('care.practitioners.edit', $practitioner) }}" class="text-sky-600">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
|
||||
No practitioners yet. Add doctors here so they appear on appointments and the queue.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -2,17 +2,59 @@
|
||||
// Shared Ladill app launcher — IDENTICAL across every app/service.
|
||||
// Driven by config/ladill_launcher.php (fully-extracted apps only) + icons
|
||||
// from public/images/launcher-icons/. Omits the current app (matched by
|
||||
// APP_URL host). To replicate elsewhere, copy this file + the config +
|
||||
// the launcher-icons folder verbatim.
|
||||
// APP_URL host). Scoped team members only see apps they can access; the
|
||||
// launcher is hidden entirely when they only have one app (or none).
|
||||
$sidebar = (bool) ($sidebar ?? false);
|
||||
$selfHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
$allowedSlugs = \App\Support\StaffUx::allowedAppSlugs(auth()->user());
|
||||
$launcherApps = array_values(array_filter(
|
||||
config('ladill_launcher.apps', []),
|
||||
fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost
|
||||
function (array $app) use ($selfHost, $allowedSlugs) {
|
||||
if (parse_url($app['url'], PHP_URL_HOST) === $selfHost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($allowedSlugs === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$host = (string) parse_url($app['url'], PHP_URL_HOST);
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
$suffix = '.'.$root;
|
||||
if (! str_ends_with($host, $suffix)) {
|
||||
return false;
|
||||
}
|
||||
$sub = substr($host, 0, -strlen($suffix));
|
||||
|
||||
foreach ($allowedSlugs as $slug) {
|
||||
if ($slug === $sub || str_starts_with($sub, $slug)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Launcher URLs may use marketing hostnames — match by known slug keys.
|
||||
$slug = match (true) {
|
||||
str_starts_with($host, 'care.') => 'care',
|
||||
str_starts_with($host, 'meet.') => 'meet',
|
||||
str_starts_with($host, 'queue.') => 'queue',
|
||||
str_starts_with($host, 'frontdesk.') => 'frontdesk',
|
||||
str_starts_with($host, 'mail.') => 'mail',
|
||||
str_starts_with($host, 'email.') => 'email',
|
||||
default => explode('.', $host)[0] ?? null,
|
||||
};
|
||||
|
||||
return $slug && in_array($slug, $allowedSlugs, true);
|
||||
}
|
||||
));
|
||||
|
||||
// Single-app staff: no launcher (they already are in their only app).
|
||||
if ($allowedSlugs !== null && count($allowedSlugs) <= 1) {
|
||||
$launcherApps = [];
|
||||
}
|
||||
|
||||
$launcherOverflows = count($launcherApps) > 15;
|
||||
@endphp
|
||||
@if (! empty($launcherApps))
|
||||
@if (! empty($launcherApps) && \App\Support\StaffUx::showProductHub(auth()->user()))
|
||||
<div class="relative" x-data="{
|
||||
appsOpen: false,
|
||||
showScrollMore: {{ $launcherOverflows ? 'true' : 'false' }},
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
$adminNav[] = ['name' => 'Departments', 'route' => route('care.departments.index'), 'active' => request()->routeIs('care.departments.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />'];
|
||||
}
|
||||
if ($permissions->can($member, 'admin.practitioners.view')) {
|
||||
$adminNav[] = ['name' => 'Practitioners', 'route' => route('care.practitioners.index'), 'active' => request()->routeIs('care.practitioners.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />'];
|
||||
}
|
||||
if ($permissions->can($member, 'admin.members.view')) {
|
||||
$adminNav[] = ['name' => 'Team', 'route' => route('care.members.index'), 'active' => request()->routeIs('care.members.*'),
|
||||
'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" />'];
|
||||
|
||||
Reference in New Issue
Block a user