Nest Branches and Team under Settings as Pro features.
Deploy Ladill Queue / deploy (push) Successful in 50s

Match Frontdesk: settings cards and nested routes, Pro-gated access,
and legacy /admin redirects to /settings/branches and /settings/team.
This commit is contained in:
isaacclad
2026-07-16 09:48:32 +00:00
parent 8be3eaeb4b
commit 26c9edc131
17 changed files with 629 additions and 165 deletions
+50 -1
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\RequiresPlanFeature;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Qms\AuditLogger;
@@ -13,11 +14,26 @@ use Illuminate\View\View;
class BranchController extends Controller
{
use RequiresPlanFeature;
use ScopesToAccount;
private const FEATURE = 'branches';
private const UPGRADE_MESSAGE = 'Multi-branch management requires Queue Pro.';
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.view');
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Branches',
'Manage locations and sites with Queue Pro. Free plans include one branch created during setup.',
)) {
return $upgrade;
}
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
@@ -26,19 +42,39 @@ class BranchController extends Controller
->orderBy('name')
->get();
return view('qms.admin.branches.index', compact('branches', 'organization'));
$heroStats = [
'total' => $branches->count(),
'active' => $branches->where('is_active', true)->count(),
'departments' => $branches->sum('departments_count'),
];
return view('qms.admin.branches.index', compact('branches', 'organization', 'heroStats'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.manage');
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Branches',
'Add and manage multiple locations with Queue Pro.',
)) {
return $upgrade;
}
return view('qms.admin.branches.create', ['organization' => $this->organization($request)]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
@@ -74,6 +110,15 @@ class BranchController extends Controller
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Branches',
'Edit branch details with Queue Pro.',
)) {
return $upgrade;
}
return view('qms.admin.branches.edit', compact('branch'));
}
@@ -82,6 +127,10 @@ class BranchController extends Controller
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Qms\Concerns;
use App\Services\Qms\PlanService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
trait RequiresPlanFeature
{
/**
* For page views: return an upgrade screen when the org lacks the feature.
*/
protected function proFeatureUpgradeView(
Request $request,
string $feature,
string $title,
string $description,
): ?View {
$organization = $this->organization($request);
$plans = app(PlanService::class);
if ($plans->hasFeature($organization, $feature)) {
return null;
}
return view('qms.settings.pro-feature', [
'organization' => $organization,
'title' => $title,
'description' => $description,
'proPriceMinor' => $plans->proPricePerBranchMinor(),
]);
}
/**
* For mutations: redirect free-plan orgs to the plans page.
*/
protected function denyWithoutFeature(
Request $request,
string $feature,
string $message,
): ?RedirectResponse {
$organization = $this->organization($request);
if (app(PlanService::class)->hasFeature($organization, $feature)) {
return null;
}
return redirect()
->route('qms.pro.index')
->with('error', $message);
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\RequiresPlanFeature;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Member;
@@ -14,11 +15,26 @@ use Illuminate\View\View;
class MemberController extends Controller
{
use RequiresPlanFeature;
use ScopesToAccount;
private const FEATURE = 'team';
private const UPGRADE_MESSAGE = 'Team management requires Queue Pro.';
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.view');
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Team',
'Invite colleagues, assign roles, and scope access by branch with Queue Pro.',
)) {
return $upgrade;
}
$organization = $this->organization($request);
$members = Member::owned($this->ownerRef($request))
@@ -46,6 +62,16 @@ class MemberController extends Controller
public function create(Request $request, IdentityTeamClient $identity): View
{
$this->authorizeAbility($request, 'admin.members.manage');
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Team',
'Invite colleagues and assign Queue roles with Pro.',
)) {
return $upgrade;
}
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
@@ -72,6 +98,11 @@ class MemberController extends Controller
public function store(Request $request, IdentityTeamClient $identity): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
@@ -122,6 +153,10 @@ class MemberController extends Controller
$this->authorizeAbility($request, 'admin.members.manage');
$this->authorizeOwner($request, $member);
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.');
$memberId = $member->id;
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Qms\AuditLogger;
use App\Services\Qms\PlanService;
use App\Services\Qms\QmsPermissions;
use App\Support\OrganizationBranding;
use Illuminate\Http\RedirectResponse;
@@ -16,11 +17,13 @@ class SettingsController extends Controller
{
use ScopesToAccount;
public function edit(Request $request): View
public function edit(Request $request, PlanService $plans): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(QmsPermissions::class)->can($this->member($request), 'settings.manage');
$member = $this->member($request);
$permissions = app(QmsPermissions::class);
$canManage = $permissions->can($member, 'settings.manage');
$branchCount = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
@@ -32,6 +35,15 @@ class SettingsController extends Controller
'branchCount' => $branchCount,
'appointmentModes' => config('qms.appointment_modes'),
'industries' => config('qms.industry_templates'),
'hasPaidPlan' => $plans->hasPaidPlan($organization),
'isPro' => $plans->isPro($organization),
'isEnterprise' => $plans->isEnterprise($organization),
'hasBranchesFeature' => $plans->hasFeature($organization, 'branches'),
'hasTeamFeature' => $plans->hasFeature($organization, 'team'),
'canViewBranches' => $permissions->can($member, 'admin.branches.view'),
'canViewTeam' => $permissions->can($member, 'admin.members.view'),
'proPriceMinor' => $plans->proPricePerBranchMinor(),
'activeBranchCount' => $plans->activeBranchCount($organization),
]);
}
+7
View File
@@ -42,6 +42,13 @@ class PlanService
return in_array($this->planKey($organization), ['pro', 'enterprise'], true);
}
public function hasFeature(Organization $organization, string $feature): bool
{
$features = config('qms.plans.'.$this->planKey($organization).'.features', []);
return in_array($feature, $features, true);
}
public function activeBranchCount(Organization $organization): int
{
return Branch::query()
+29
View File
@@ -180,6 +180,12 @@ return [
'price_minor' => 0,
'max_branches' => 1,
'max_queues' => 5,
'features' => [
'queues',
'tickets',
'counters',
'displays',
],
],
'pro' => [
'label' => 'Pro',
@@ -189,12 +195,35 @@ return [
),
'max_branches' => null,
'max_queues' => null,
'features' => [
'queues',
'tickets',
'counters',
'displays',
'branches',
'team',
'routing',
'integrations',
'reports',
],
],
'enterprise' => [
'label' => 'Enterprise',
'price_minor_per_branch' => (int) env('QUEUE_ENTERPRISE_PRICE_PER_BRANCH_MINOR', 449000),
'max_branches' => null,
'max_queues' => null,
'features' => [
'queues',
'tickets',
'counters',
'displays',
'branches',
'team',
'routing',
'integrations',
'reports',
'sla',
],
],
],
+4 -9
View File
@@ -51,24 +51,19 @@
}
$adminNav = [];
if ($permissions->can($member, 'admin.branches.view')) {
$adminNav[] = ['name' => 'Branches', 'route' => route('qms.branches.index'), 'active' => request()->routeIs('qms.branches.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m-1.5 3h1.5m3-9H15m-1.5 3H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />'];
}
if ($permissions->can($member, 'admin.departments.view')) {
$adminNav[] = ['name' => 'Departments', 'route' => route('qms.departments.index'), 'active' => request()->routeIs('qms.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.members.view')) {
$adminNav[] = ['name' => 'Team', 'route' => route('qms.members.index'), 'active' => request()->routeIs('qms.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" />'];
}
if ($permissions->can($member, 'audit.view')) {
$adminNav[] = ['name' => 'Audit log', 'route' => route('qms.audit.index'), 'active' => request()->routeIs('qms.audit.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />'];
}
$settingsActive = request()->routeIs('qms.settings.*') && ! request()->routeIs('qms.pro.*');
$settingsActive = (request()->routeIs('qms.settings.*')
|| request()->routeIs('qms.branches.*')
|| request()->routeIs('qms.members.*'))
&& ! request()->routeIs('qms.pro.*');
@endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@foreach ($nav as $item)
@@ -1,13 +1,39 @@
<x-app-layout title="New branch">
<div class="mx-auto max-w-lg">
<h1 class="text-2xl font-semibold">New branch</h1>
<form method="POST" action="{{ route('qms.branches.store') }}" class="mt-6 space-y-4 rounded-2xl bg-white p-6 shadow-sm">
@csrf
<div><label class="block text-sm font-medium">Name</label><input name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
<div><label class="block text-sm font-medium">Code</label><input name="code" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
<div><label class="block text-sm font-medium">Address</label><input name="address" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
<div><label class="block text-sm font-medium">Phone</label><input name="phone" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
<button type="submit" class="btn-primary w-full">Create branch</button>
</form>
</div>
<x-app-layout title="Add branch">
<x-settings.page title="Add branch" description="Create a new location for queues, counters, and staff.">
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm">
<a href="{{ route('qms.settings.edit') }}" class="text-slate-500 hover:text-slate-800">Settings</a>
<span class="text-slate-300">/</span>
<a href="{{ route('qms.branches.index') }}" class="text-slate-500 hover:text-slate-800">Branches</a>
<span class="text-slate-300">/</span>
<span class="font-medium text-slate-900">Add</span>
</div>
@include('partials.flash')
<x-settings.card title="Branch details">
<form method="POST" action="{{ route('qms.branches.store') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700">Name</label>
<input type="text" name="name" value="{{ old('name') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Code</label>
<input type="text" name="code" value="{{ old('code') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Address</label>
<input type="text" name="address" value="{{ old('address') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Phone</label>
<input type="tel" name="phone" value="{{ old('phone') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div class="flex justify-end gap-3">
<a href="{{ route('qms.branches.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">Save</button>
</div>
</form>
</x-settings.card>
</x-settings.page>
</x-app-layout>
@@ -1,14 +1,43 @@
<x-app-layout title="Edit branch">
<div class="mx-auto max-w-lg">
<h1 class="text-2xl font-semibold">Edit branch</h1>
<form method="POST" action="{{ route('qms.branches.update', $branch) }}" class="mt-6 space-y-4 rounded-2xl bg-white p-6 shadow-sm">
@csrf @method('PUT')
<div><label class="block text-sm font-medium">Name</label><input name="name" value="{{ $branch->name }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
<div><label class="block text-sm font-medium">Code</label><input name="code" value="{{ $branch->code }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
<div><label class="block text-sm font-medium">Address</label><input name="address" value="{{ $branch->address }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
<div><label class="block text-sm font-medium">Phone</label><input name="phone" value="{{ $branch->phone }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" @checked($branch->is_active)> Active</label>
<button type="submit" class="btn-primary w-full">Save changes</button>
</form>
</div>
<x-settings.page title="Edit branch" description="Update location details for queues, counters, and staff.">
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm">
<a href="{{ route('qms.settings.edit') }}" class="text-slate-500 hover:text-slate-800">Settings</a>
<span class="text-slate-300">/</span>
<a href="{{ route('qms.branches.index') }}" class="text-slate-500 hover:text-slate-800">Branches</a>
<span class="text-slate-300">/</span>
<span class="font-medium text-slate-900">{{ $branch->name }}</span>
</div>
@include('partials.flash')
<x-settings.card title="Branch details">
<form method="POST" action="{{ route('qms.branches.update', $branch) }}" class="space-y-4">
@csrf @method('PUT')
<div>
<label class="block text-sm font-medium text-slate-700">Name</label>
<input type="text" name="name" value="{{ old('name', $branch->name) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Code</label>
<input type="text" name="code" value="{{ old('code', $branch->code) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Address</label>
<input type="text" name="address" value="{{ old('address', $branch->address) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Phone</label>
<input type="tel" name="phone" value="{{ old('phone', $branch->phone) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</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', $branch->is_active))>
Active
</label>
<div class="flex justify-end gap-3">
<a href="{{ route('qms.branches.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">Save changes</button>
</div>
</form>
</x-settings.card>
</x-settings.page>
</x-app-layout>
@@ -3,38 +3,48 @@
app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()),
'admin.branches.manage'
);
$canViewTeam = app(\App\Services\Qms\QmsPermissions::class)->can(
app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()),
'admin.members.view'
);
@endphp
<x-app-layout title="Branches">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-slate-900">Branches</h1>
@if ($canManageBranches)
<a href="{{ route('qms.branches.create') }}" class="btn-primary">New branch</a>
@endif
</div>
<x-settings.page title="Branches" description="Organize locations and sites so queues, counters, and staff stay scoped to the right branch.">
<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('qms.settings.edit') }}" class="text-slate-500 hover:text-slate-800">Settings</a>
<span class="text-slate-300">/</span>
<span class="font-medium text-slate-900">Branches</span>
@if ($canViewTeam)
<a href="{{ route('qms.members.index') }}" class="ml-3 text-indigo-600 hover:text-indigo-800">Team</a>
@endif
</div>
@if ($canManageBranches)
<a href="{{ route('qms.branches.create') }}" class="btn-primary">Add branch</a>
@endif
</div>
@include('partials.flash')
@include('partials.flash')
<div class="overflow-hidden rounded-2xl bg-white shadow-sm">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50"><tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Name</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Code</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Departments</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th>
<th></th>
</tr></thead>
<tbody class="divide-y divide-slate-200">
@foreach ($branches as $branch)
<tr>
<td class="px-4 py-3 text-sm font-medium">{{ $branch->name }}</td>
<td class="px-4 py-3 text-sm">{{ $branch->code ?? '—' }}</td>
<td class="px-4 py-3 text-sm">{{ $branch->departments_count }}</td>
<td class="px-4 py-3 text-sm">{{ $branch->is_active ? 'Active' : 'Inactive' }}</td>
<td class="px-4 py-3 text-right"><a href="{{ route('qms.branches.edit', $branch) }}" class="text-sm text-indigo-600">Edit</a></td>
</tr>
@endforeach
</tbody>
</table>
</div>
<x-settings.card title="All branches" description="{{ number_format($heroStats['total']) }} total · {{ number_format($heroStats['active']) }} active · {{ number_format($heroStats['departments']) }} department(s)">
@forelse ($branches as $branch)
<div class="flex items-center justify-between gap-4 border-b border-slate-50 py-3 last:border-b-0">
<div>
<p class="font-medium text-slate-900">{{ $branch->name }}</p>
<p class="text-sm text-slate-500">
{{ $branch->code ?? 'No code' }}
· {{ $branch->departments_count }} department(s)
· {{ $branch->is_active ? 'Active' : 'Inactive' }}
</p>
</div>
@if ($canManageBranches)
<a href="{{ route('qms.branches.edit', $branch) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Edit</a>
@endif
</div>
@empty
<p class="text-sm text-slate-500">No branches yet.</p>
@endforelse
</x-settings.card>
</x-settings.page>
</x-app-layout>
@@ -1,52 +1,66 @@
<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>
<x-settings.page title="Invite team member" description="They get access only to Ladill Queue (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('qms.settings.edit') }}" class="text-slate-500 hover:text-slate-800">Settings</a>
<span class="text-slate-300">/</span>
<a href="{{ route('qms.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" x-data action="{{ route('qms.members.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
<x-settings.card title="Invitation">
<form method="POST" action="{{ route('qms.members.store') }}" class="space-y-4" x-data>
@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" x-ref="email" value="{{ old('email') }}" required
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 Queue 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" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($roles as $v => $l)
<option value="{{ $v }}" @selected(old('role') === $v)>{{ $l }}</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" x-ref="email" value="{{ old('email') }}" required
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 Queue organization.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch scope (optional)</label>
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Organization-wide</option>
@foreach ($branches as $b)
<option value="{{ $b->id }}" @selected(old('branch_id') == $b->id)>{{ $b->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Role</label>
<select name="role" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($roles as $v => $l)
<option value="{{ $v }}" @selected(old('role') === $v)>{{ $l }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch scope (optional)</label>
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">Organization-wide</option>
@foreach ($branches as $b)
<option value="{{ $b->id }}" @selected(old('branch_id') == $b->id)>{{ $b->name }}</option>
@endforeach
</select>
</div>
<button type="submit" class="btn-primary w-full">Send invitation</button>
</form>
</div>
<div class="flex justify-end gap-3">
<a href="{{ route('qms.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>
@@ -3,56 +3,69 @@
app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()),
'admin.members.manage'
);
$canViewBranches = app(\App\Services\Qms\QmsPermissions::class)->can(
app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()),
'admin.branches.view'
);
@endphp
<x-app-layout title="Team">
<div class="space-y-6">
<x-qms.page-hero
badge="Roles · Access · Branch assignment"
title="Team"
description="Manage who can access Queue, their roles, and which branch each operator or admin belongs to."
:stats="[
['value' => number_format($heroStats['total']), 'label' => 'Members'],
['value' => number_format($heroStats['operators']), 'label' => 'Operators'],
['value' => number_format($heroStats['branches']), 'label' => 'Branches staffed'],
]">
<x-settings.page title="Team" description="Manage who can access Queue, 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('qms.settings.edit') }}" 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('qms.branches.index') }}" class="ml-3 text-indigo-600 hover:text-indigo-800">Branches</a>
@endif
</div>
@if ($canManageMembers)
<x-slot name="actions">
<a href="{{ route('qms.members.create') }}" class="btn-primary">Add member</a>
</x-slot>
<a href="{{ route('qms.members.create') }}" class="btn-primary">Add member</a>
@endif
</x-qms.page-hero>
</div>
@include('partials.flash')
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50"><tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Member</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Role</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Branch</th>
<th></th>
</tr></thead>
<tbody class="divide-y divide-slate-200">
@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 text-sm">{{ $roles[$member->role] ?? $member->role }}</td>
<td class="px-4 py-3 text-sm">{{ $member->branch?->name ?? 'All' }}</td>
<td class="px-4 py-3 text-right">
@if ($member->user_ref !== auth()->user()->public_id)
<form method="POST" action="{{ route('qms.members.destroy', $member) }}">@csrf @method('DELETE')<x-btn type="submit" variant="danger" size="sm">Remove</x-btn></form>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<x-settings.card title="Team members" description="{{ number_format($heroStats['total']) }} members · {{ number_format($heroStats['operators']) }} operators · {{ number_format($heroStats['branches']) }} branches staffed">
@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 $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="py-3 pr-4">{{ $display }}</td>
<td class="py-3 pr-4">{{ $roles[$member->role] ?? $member->role }}</td>
<td class="py-3 pr-4">{{ $member->branch?->name ?? 'All branches' }}</td>
<td class="py-3 text-right">
@if ($canManageMembers && $member->user_ref !== auth()->user()->public_id)
<form method="POST" action="{{ route('qms.members.destroy', $member) }}" class="inline">
@csrf @method('DELETE')
<button type="submit" class="text-sm font-medium text-red-600 hover:text-red-800">Remove</button>
</form>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</x-settings.card>
</x-settings.page>
</x-app-layout>
+2
View File
@@ -94,6 +94,8 @@
= {{ $currency }} <span x-text="fmt(proTotal())"></span>/mo
</p>
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
<li>Multi-branch locations</li>
<li>Team roles & invitations</li>
<li>Advanced routing rules</li>
<li>Unlimited kiosks & displays</li>
<li>Ladill-provided queue kiosk, display and audio system</li>
+59 -3
View File
@@ -1,5 +1,63 @@
<x-app-layout title="Settings">
<x-settings.page title="Organization settings">
<x-settings.page title="Organization settings" description="Configure branding, queue preferences, and access for {{ $organization->name }}.">
@if ($canManage)
<x-settings.card title="Plan & usage" description="Subscription limits for {{ $organization->name }}.">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<p class="text-sm leading-6 text-slate-600">
@if ($isEnterprise)
<span class="font-medium text-indigo-700">Enterprise</span> unlimited branches, full API, advanced analytics, billed per branch.
@elseif ($hasPaidPlan)
<span class="font-medium text-indigo-700">Pro</span> multi-branch, team roles, advanced routing, and Ladill-provided kiosk hardware, billed per branch.
@else
<span class="font-medium text-slate-900">Free</span> one branch, five queues, owner-only access. Multi-branch and team require Pro.
@endif
</p>
@if (! $hasPaidPlan)
<a href="{{ route('qms.pro.index') }}" class="btn-primary shrink-0 text-sm">Upgrade to Pro (GHS {{ number_format($proPriceMinor / 100, 0) }}/branch/mo)</a>
@else
<a href="{{ route('qms.pro.index') }}" class="btn-secondary shrink-0 text-sm">Manage plan</a>
@endif
</div>
</x-settings.card>
@endif
@if ($canViewBranches || $canViewTeam)
<x-settings.card title="Branches & team" description="Locations and staff access for this organization. Multi-branch and team require Queue Pro.">
<ul class="space-y-3">
@if ($canViewBranches)
<li>
<a href="{{ route('qms.branches.index') }}"
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
<span>
Branches
@if (! $hasBranchesFeature)
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Pro</span>
@endif
<span class="mt-0.5 block text-xs text-slate-500">Locations and sites for queues and counters</span>
</span>
<span class="text-slate-400">&rarr;</span>
</a>
</li>
@endif
@if ($canViewTeam)
<li>
<a href="{{ route('qms.members.index') }}"
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
<span>
Team
@if (! $hasTeamFeature)
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Pro</span>
@endif
<span class="mt-0.5 block text-xs text-slate-500">Invite colleagues and assign Queue roles</span>
</span>
<span class="text-slate-400">&rarr;</span>
</a>
</li>
@endif
</ul>
</x-settings.card>
@endif
<form method="POST" action="{{ route('qms.settings.update') }}" enctype="multipart/form-data" class="space-y-6">
@csrf @method('PUT')
@@ -72,7 +130,5 @@
</div>
@endif
</form>
<p class="text-sm text-slate-600">{{ $branchCount }} branch{{ $branchCount === 1 ? '' : 'es' }} configured.</p>
</x-settings.page>
</x-app-layout>
@@ -0,0 +1,20 @@
<x-app-layout :title="$title">
<x-settings.page :title="$title" :description="$description">
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm">
<a href="{{ route('qms.settings.edit') }}" class="text-slate-500 hover:text-slate-800">Settings</a>
<span class="text-slate-300">/</span>
<span class="font-medium text-slate-900">{{ $title }}</span>
</div>
<x-settings.card title="Upgrade your plan">
<p class="text-sm text-slate-600">
GHS {{ number_format($proPriceMinor / 100, 0) }} / branch / month for Pro from your Ladill wallet
multi-branch locations, team roles, advanced routing, and Ladill-provided kiosk hardware.
</p>
<div class="mt-4 flex flex-wrap items-center gap-3">
<a href="{{ route('qms.pro.index') }}" class="btn-primary">View plans</a>
<a href="{{ route('qms.wallet') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Top up</a>
</div>
</x-settings.card>
</x-settings.page>
</x-app-layout>
+14 -11
View File
@@ -147,12 +147,6 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/feedback', [FeedbackAdminController::class, 'index'])->name('qms.feedback.admin');
Route::get('/admin/branches', [BranchController::class, 'index'])->name('qms.branches.index');
Route::get('/admin/branches/create', [BranchController::class, 'create'])->name('qms.branches.create');
Route::post('/admin/branches', [BranchController::class, 'store'])->name('qms.branches.store');
Route::get('/admin/branches/{branch}/edit', [BranchController::class, 'edit'])->name('qms.branches.edit');
Route::put('/admin/branches/{branch}', [BranchController::class, 'update'])->name('qms.branches.update');
Route::get('/admin/departments', [DepartmentController::class, 'index'])->name('qms.departments.index');
Route::get('/admin/departments/create', [DepartmentController::class, 'create'])->name('qms.departments.create');
Route::post('/admin/departments', [DepartmentController::class, 'store'])->name('qms.departments.store');
@@ -160,17 +154,26 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::put('/admin/departments/{department}', [DepartmentController::class, 'update'])->name('qms.departments.update');
Route::delete('/admin/departments/{department}', [DepartmentController::class, 'destroy'])->name('qms.departments.destroy');
Route::get('/admin/members', [MemberController::class, 'index'])->name('qms.members.index');
Route::get('/admin/members/create', [MemberController::class, 'create'])->name('qms.members.create');
Route::post('/admin/members', [MemberController::class, 'store'])->name('qms.members.store');
Route::delete('/admin/members/{member}', [MemberController::class, 'destroy'])->name('qms.members.destroy');
Route::get('/admin/audit', [AuditLogController::class, 'index'])->name('qms.audit.index');
Route::get('/admin/audit/export', [AuditLogController::class, 'export'])->name('qms.audit.export');
Route::get('/settings', [SettingsController::class, 'edit'])->name('qms.settings.edit');
Route::put('/settings', [SettingsController::class, 'update'])->name('qms.settings.update');
Route::get('/settings/branches', [BranchController::class, 'index'])->name('qms.branches.index');
Route::get('/settings/branches/create', [BranchController::class, 'create'])->name('qms.branches.create');
Route::post('/settings/branches', [BranchController::class, 'store'])->name('qms.branches.store');
Route::get('/settings/branches/{branch}/edit', [BranchController::class, 'edit'])->name('qms.branches.edit');
Route::put('/settings/branches/{branch}', [BranchController::class, 'update'])->name('qms.branches.update');
Route::get('/settings/team', [MemberController::class, 'index'])->name('qms.members.index');
Route::get('/settings/team/create', [MemberController::class, 'create'])->name('qms.members.create');
Route::post('/settings/team', [MemberController::class, 'store'])->name('qms.members.store');
Route::delete('/settings/team/{member}', [MemberController::class, 'destroy'])->name('qms.members.destroy');
Route::redirect('/admin/branches', '/settings/branches');
Route::redirect('/admin/members', '/settings/team');
Route::get('/pro', [ProController::class, 'index'])->name('qms.pro.index');
Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('qms.pro.subscribe');
Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('qms.pro.subscribe-enterprise');
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\User;
use App\Services\Qms\OrganizationResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SettingsBranchesTeamTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected \App\Models\Organization $organization;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->owner = User::create([
'public_id' => 'queue-settings-owner-001',
'name' => 'Owner',
'email' => 'settings-owner@example.com',
'password' => bcrypt('password'),
]);
$this->organization = app(OrganizationResolver::class)->completeOnboarding($this->owner, [
'organization_name' => 'Settings Org',
'industry' => 'retail',
'appointment_mode' => 'hybrid',
'branch_name' => 'Main',
'timezone' => 'UTC',
]);
}
public function test_settings_page_links_to_branches_and_team_with_pro_badges(): void
{
$this->actingAs($this->owner)
->get(route('qms.settings.edit'))
->assertOk()
->assertSee('Branches & team')
->assertSee('Branches')
->assertSee('Team')
->assertSee('Pro')
->assertSee(route('qms.branches.index', absolute: false))
->assertSee(route('qms.members.index', absolute: false));
}
public function test_free_plan_branches_shows_upgrade_screen(): void
{
$this->actingAs($this->owner)
->get(route('qms.branches.index'))
->assertOk()
->assertSee('Upgrade your plan')
->assertSee('View plans')
->assertSee(route('qms.pro.index', absolute: false));
}
public function test_free_plan_team_shows_upgrade_screen(): void
{
$this->actingAs($this->owner)
->get(route('qms.members.index'))
->assertOk()
->assertSee('Upgrade your plan')
->assertSee('Team');
}
public function test_free_plan_cannot_store_member(): void
{
$this->actingAs($this->owner)
->post(route('qms.members.store'), [
'email' => 'colleague@example.com',
'role' => 'queue_operator',
])
->assertRedirect(route('qms.pro.index'))
->assertSessionHas('error');
}
public function test_pro_plan_can_view_branches(): void
{
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'pro_billed_branches' => 1,
]),
]);
$this->actingAs($this->owner)
->get(route('qms.branches.index'))
->assertOk()
->assertSee('All branches')
->assertSee('Main')
->assertSee('Settings');
}
public function test_legacy_admin_routes_redirect_to_settings(): void
{
$this->actingAs($this->owner)
->get('/admin/branches')
->assertRedirect('/settings/branches');
$this->actingAs($this->owner)
->get('/admin/members')
->assertRedirect('/settings/team');
}
}