Move Branches and Team into Settings as Pro features.
Deploy Ladill Frontdesk / deploy (push) Successful in 48s

Gate multi-branch and team management behind paid plans, nest their
routes under Settings, and remove them from the sidebar Administration
section.
This commit is contained in:
isaacclad
2026-07-16 08:13:42 +00:00
parent 59433f2b7b
commit 5526a10342
19 changed files with 377 additions and 50 deletions
@@ -3,19 +3,36 @@
namespace App\Http\Controllers\Frontdesk; namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount; use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch; use App\Models\Branch;
use App\Services\Frontdesk\PlanService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
class BranchController extends Controller class BranchController extends Controller
{ {
use RequiresPlanFeature;
use ScopesToAccount; use ScopesToAccount;
private const FEATURE = 'branches';
private const UPGRADE_MESSAGE = 'Multi-branch management requires Frontdesk Pro.';
public function index(Request $request): View public function index(Request $request): View
{ {
$this->authorizeAbility($request, 'admin.branches.view'); $this->authorizeAbility($request, 'admin.branches.view');
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Branches',
'Manage locations, buildings, and reception desks with Frontdesk Pro. Free plans include one branch created during setup.',
)) {
return $upgrade;
}
$organization = $this->organization($request); $organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request)) $branches = Branch::owned($this->ownerRef($request))
@@ -36,14 +53,29 @@ class BranchController extends Controller
public function create(Request $request): View public function create(Request $request): View
{ {
$this->authorizeAbility($request, 'admin.branches.manage'); $this->authorizeAbility($request, 'admin.branches.manage');
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Branches',
'Add and manage multiple locations with Frontdesk Pro.',
)) {
return $upgrade;
}
return view('frontdesk.admin.branches.create', ['organization' => $this->organization($request)]); return view('frontdesk.admin.branches.create', ['organization' => $this->organization($request)]);
} }
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$this->authorizeAbility($request, 'admin.branches.manage'); $this->authorizeAbility($request, 'admin.branches.manage');
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$organization = $this->organization($request); $organization = $this->organization($request);
$plans = app(\App\Services\Frontdesk\PlanService::class); $plans = app(PlanService::class);
$currentCount = Branch::owned($this->ownerRef($request)) $currentCount = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id) ->where('organization_id', $organization->id)
@@ -75,6 +107,15 @@ class BranchController extends Controller
$this->authorizeAbility($request, 'admin.branches.manage'); $this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch); $this->authorizeOwner($request, $branch);
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Branches',
'Edit branch details with Frontdesk Pro.',
)) {
return $upgrade;
}
return view('frontdesk.admin.branches.edit', compact('branch')); return view('frontdesk.admin.branches.edit', compact('branch'));
} }
@@ -83,6 +124,10 @@ class BranchController extends Controller
$this->authorizeAbility($request, 'admin.branches.manage'); $this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch); $this->authorizeOwner($request, $branch);
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$validated = $request->validate([ $validated = $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'], 'code' => ['nullable', 'string', 'max:50'],
@@ -93,7 +138,7 @@ class BranchController extends Controller
$activating = $request->boolean('is_active') && ! $branch->is_active; $activating = $request->boolean('is_active') && ! $branch->is_active;
if ($activating) { if ($activating) {
$plans = app(\App\Services\Frontdesk\PlanService::class); $plans = app(PlanService::class);
$organization = $this->organization($request); $organization = $this->organization($request);
if (! $plans->canActivateBranch($organization)) { if (! $plans->canActivateBranch($organization)) {
return back()->withInput()->with('error', $plans->branchLimitMessage($organization)); return back()->withInput()->with('error', $plans->branchLimitMessage($organization));
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Frontdesk; namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount; use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch; use App\Models\Branch;
use App\Models\Building; use App\Models\Building;
@@ -12,13 +13,27 @@ use Illuminate\View\View;
class BuildingController extends Controller class BuildingController extends Controller
{ {
use RequiresPlanFeature;
use ScopesToAccount; use ScopesToAccount;
private const FEATURE = 'branches';
private const UPGRADE_MESSAGE = 'Multi-branch management requires Frontdesk Pro.';
public function index(Request $request, Branch $branch): View public function index(Request $request, Branch $branch): View
{ {
$this->authorizeAbility($request, 'admin.branches.view'); $this->authorizeAbility($request, 'admin.branches.view');
$this->authorizeOwner($request, $branch); $this->authorizeOwner($request, $branch);
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Branches',
'Manage buildings and reception desks with Frontdesk Pro.',
)) {
return $upgrade;
}
$buildings = $branch->buildings()->withCount('receptionDesks')->orderBy('name')->get(); $buildings = $branch->buildings()->withCount('receptionDesks')->orderBy('name')->get();
return view('frontdesk.admin.buildings.index', compact('branch', 'buildings')); return view('frontdesk.admin.buildings.index', compact('branch', 'buildings'));
@@ -29,6 +44,10 @@ class BuildingController extends Controller
$this->authorizeAbility($request, 'admin.branches.manage'); $this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch); $this->authorizeOwner($request, $branch);
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$validated = $request->validate([ $validated = $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'floor_count' => ['nullable', 'string', 'max:20'], 'floor_count' => ['nullable', 'string', 'max:20'],
@@ -49,6 +68,10 @@ class BuildingController extends Controller
$this->authorizeOwner($request, $building); $this->authorizeOwner($request, $building);
abort_unless($building->branch_id === $branch->id, 404); abort_unless($building->branch_id === $branch->id, 404);
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$building->delete(); $building->delete();
return back()->with('success', 'Building removed.'); return back()->with('success', 'Building removed.');
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Frontdesk\Concerns;
use App\Services\Frontdesk\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('frontdesk.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('frontdesk.pro.index')
->with('error', $message);
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Frontdesk; namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount; use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch; use App\Models\Branch;
use App\Models\Member; use App\Models\Member;
@@ -13,11 +14,26 @@ use Illuminate\View\View;
class MemberController extends Controller class MemberController extends Controller
{ {
use RequiresPlanFeature;
use ScopesToAccount; use ScopesToAccount;
private const FEATURE = 'team';
private const UPGRADE_MESSAGE = 'Team management requires Frontdesk Pro.';
public function index(Request $request): View public function index(Request $request): View
{ {
$this->authorizeAbility($request, 'admin.members.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 Frontdesk Pro.',
)) {
return $upgrade;
}
$organization = $this->organization($request); $organization = $this->organization($request);
$members = Member::owned($this->ownerRef($request)) $members = Member::owned($this->ownerRef($request))
@@ -45,6 +61,16 @@ class MemberController extends Controller
public function create(Request $request, IdentityTeamClient $identity): View public function create(Request $request, IdentityTeamClient $identity): View
{ {
$this->authorizeAbility($request, 'admin.members.manage'); $this->authorizeAbility($request, 'admin.members.manage');
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Team',
'Invite team members with Frontdesk Pro.',
)) {
return $upgrade;
}
$organization = $this->organization($request); $organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request)) $branches = Branch::owned($this->ownerRef($request))
@@ -71,6 +97,11 @@ class MemberController extends Controller
public function store(Request $request, IdentityTeamClient $identity): RedirectResponse public function store(Request $request, IdentityTeamClient $identity): RedirectResponse
{ {
$this->authorizeAbility($request, 'admin.members.manage'); $this->authorizeAbility($request, 'admin.members.manage');
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$organization = $this->organization($request); $organization = $this->organization($request);
$owner = $this->ownerRef($request); $owner = $this->ownerRef($request);
@@ -119,6 +150,10 @@ class MemberController extends Controller
$this->authorizeAbility($request, 'admin.members.manage'); $this->authorizeAbility($request, 'admin.members.manage');
$this->authorizeOwner($request, $member); $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.'); abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.');
$member->delete(); $member->delete();
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Frontdesk; namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount; use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Building; use App\Models\Building;
use App\Models\ReceptionDesk; use App\Models\ReceptionDesk;
@@ -12,13 +13,27 @@ use Illuminate\View\View;
class ReceptionDeskController extends Controller class ReceptionDeskController extends Controller
{ {
use RequiresPlanFeature;
use ScopesToAccount; use ScopesToAccount;
private const FEATURE = 'branches';
private const UPGRADE_MESSAGE = 'Multi-branch management requires Frontdesk Pro.';
public function index(Request $request, Building $building): View public function index(Request $request, Building $building): View
{ {
$this->authorizeAbility($request, 'admin.desks.view'); $this->authorizeAbility($request, 'admin.desks.view');
$this->authorizeOwner($request, $building); $this->authorizeOwner($request, $building);
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Branches',
'Manage reception desks with Frontdesk Pro.',
)) {
return $upgrade;
}
$desks = $building->receptionDesks()->orderBy('name')->get(); $desks = $building->receptionDesks()->orderBy('name')->get();
return view('frontdesk.admin.desks.index', compact('building', 'desks')); return view('frontdesk.admin.desks.index', compact('building', 'desks'));
@@ -29,6 +44,10 @@ class ReceptionDeskController extends Controller
$this->authorizeAbility($request, 'admin.branches.manage'); $this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $building); $this->authorizeOwner($request, $building);
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$validated = $request->validate([ $validated = $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'location' => ['nullable', 'string', 'max:255'], 'location' => ['nullable', 'string', 'max:255'],
@@ -50,6 +69,10 @@ class ReceptionDeskController extends Controller
$this->authorizeOwner($request, $desk); $this->authorizeOwner($request, $desk);
abort_unless($desk->building_id === $building->id, 404); abort_unless($desk->building_id === $building->id, 404);
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
return $deny;
}
$desk->delete(); $desk->delete();
return back()->with('success', 'Reception desk removed.'); return back()->with('success', 'Reception desk removed.');
@@ -32,6 +32,8 @@ class SettingsController extends Controller
$monthlyUsage = $usage->forOrganization($organization); $monthlyUsage = $usage->forOrganization($organization);
$plan = $plans->plan($organization); $plan = $plans->plan($organization);
$freeEmailAllowance = $plans->freeEmailsPerMonth($organization); $freeEmailAllowance = $plans->freeEmailsPerMonth($organization);
$member = $this->member($request);
$permissions = app(FrontdeskPermissions::class);
return view('frontdesk.settings.edit', [ return view('frontdesk.settings.edit', [
'organization' => $organization, 'organization' => $organization,
@@ -45,6 +47,10 @@ class SettingsController extends Controller
'isPro' => $plans->isPro($organization), 'isPro' => $plans->isPro($organization),
'isEnterprise' => $plans->isEnterprise($organization), 'isEnterprise' => $plans->isEnterprise($organization),
'hasPaidPlan' => $plans->hasPaidPlan($organization), 'hasPaidPlan' => $plans->hasPaidPlan($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'),
'billedBranches' => $plans->billedBranches($organization), 'billedBranches' => $plans->billedBranches($organization),
'activeBranchCount' => $plans->activeBranchCount($organization), 'activeBranchCount' => $plans->activeBranchCount($organization),
'proPriceMinor' => $plans->proPricePerBranchMinor(), 'proPriceMinor' => $plans->proPricePerBranchMinor(),
+4
View File
@@ -160,6 +160,8 @@ return [
'hosts', 'hosts',
'badges', 'badges',
'watchlist', 'watchlist',
'branches',
'team',
'integrations', 'integrations',
'webhooks', 'webhooks',
'scheduled_reports', 'scheduled_reports',
@@ -178,6 +180,8 @@ return [
'hosts', 'hosts',
'badges', 'badges',
'watchlist', 'watchlist',
'branches',
'team',
'integrations', 'integrations',
'webhooks', 'webhooks',
'scheduled_reports', 'scheduled_reports',
@@ -1,6 +1,7 @@
<x-app-layout title="Add branch"> <x-app-layout title="Add branch">
<div class="mx-auto max-w-lg"> <div class="mx-auto max-w-lg">
<h1 class="text-xl font-semibold text-slate-900">Add branch</h1> <a href="{{ route('frontdesk.branches.index') }}" class="text-sm text-slate-500 hover:text-slate-800">&larr; Branches</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">Add branch</h1>
<form method="POST" action="{{ route('frontdesk.branches.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6"> <form method="POST" action="{{ route('frontdesk.branches.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @csrf
<div><label class="block text-sm font-medium">Name</label><input type="text" name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div> <div><label class="block text-sm font-medium">Name</label><input type="text" name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
@@ -1,6 +1,7 @@
<x-app-layout title="Edit branch"> <x-app-layout title="Edit branch">
<div class="mx-auto max-w-lg"> <div class="mx-auto max-w-lg">
<h1 class="text-xl font-semibold text-slate-900">Edit branch</h1> <a href="{{ route('frontdesk.branches.index') }}" class="text-sm text-slate-500 hover:text-slate-800">&larr; Branches</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">Edit branch</h1>
<form method="POST" action="{{ route('frontdesk.branches.update', $branch) }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6"> <form method="POST" action="{{ route('frontdesk.branches.update', $branch) }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @method('PUT') @csrf @method('PUT')
<div><label class="block text-sm font-medium">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">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>
@@ -7,8 +7,9 @@
<x-app-layout title="Branches"> <x-app-layout title="Branches">
<div class="space-y-6"> <div class="space-y-6">
<a href="{{ route('frontdesk.settings') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-800">&larr; Settings</a>
<x-frontdesk.page-hero <x-frontdesk.page-hero
badge="Locations · Buildings · Reception desks" badge="Settings · Locations · Buildings"
title="Branches" title="Branches"
description="Organize your sites, buildings, and reception desks so visits and devices stay scoped to the right location." description="Organize your sites, buildings, and reception desks so visits and devices stay scoped to the right location."
:stats="[ :stats="[
@@ -1,6 +1,7 @@
<x-app-layout title="Add member"> <x-app-layout title="Add member">
<div class="mx-auto max-w-lg"> <div class="mx-auto max-w-lg">
<h1 class="text-xl font-semibold text-slate-900">Invite team member</h1> <a href="{{ route('frontdesk.members.index') }}" class="text-sm text-slate-500 hover:text-slate-800">&larr; Team</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">Invite team member</h1>
<form method="POST" x-data action="{{ route('frontdesk.members.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6"> <form method="POST" x-data action="{{ route('frontdesk.members.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @csrf
@@ -1,7 +1,8 @@
<x-app-layout title="Team"> <x-app-layout title="Team">
<div class="space-y-6"> <div class="space-y-6">
<a href="{{ route('frontdesk.settings') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-800">&larr; Settings</a>
<x-frontdesk.page-hero <x-frontdesk.page-hero
badge="Roles · Access · Branch scope" badge="Settings · Roles · Access"
title="Team" title="Team"
description="Invite colleagues, assign Frontdesk roles, and limit branch access for reception and security staff." description="Invite colleagues, assign Frontdesk roles, and limit branch access for reception and security staff."
:stats="[ :stats="[
@@ -72,11 +72,12 @@
<div class="flex flex-col rounded-2xl border border-slate-200 bg-white p-6 {{ $planKey === 'free' ? 'ring-2 ring-indigo-500' : '' }}"> <div class="flex flex-col rounded-2xl border border-slate-200 bg-white p-6 {{ $planKey === 'free' ? 'ring-2 ring-indigo-500' : '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Free</p> <p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Free</p>
<p class="mt-2 text-3xl font-bold text-slate-900">GHS 0</p> <p class="mt-2 text-3xl font-bold text-slate-900">GHS 0</p>
<p class="mt-1 text-sm text-slate-500">1 branch · 1 kiosk</p> <p class="mt-1 text-sm text-slate-500">1 branch · 1 kiosk · owner only</p>
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700"> <ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
<li>Core check-in & badges</li> <li>Core check-in & badges</li>
<li>100 host emails / month</li> <li>100 host emails / month</li>
<li>Watchlist & hosts</li> <li>Watchlist & hosts</li>
<li class="text-slate-400">Multi-branch & team (Pro)</li>
</ul> </ul>
@if ($planKey === 'free') @if ($planKey === 'free')
<p class="mt-6 text-sm font-medium text-indigo-700">Current plan</p> <p class="mt-6 text-sm font-medium text-indigo-700">Current plan</p>
@@ -97,6 +98,8 @@
= {{ $currency }} <span x-text="fmt(proTotal())"></span>/mo = {{ $currency }} <span x-text="fmt(proTotal())"></span>/mo
</p> </p>
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700"> <ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
<li>Multi-branch locations & buildings</li>
<li>Team roles & invitations</li>
<li>Unlimited host email alerts</li> <li>Unlimited host email alerts</li>
<li>Webhooks & iCal feeds</li> <li>Webhooks & iCal feeds</li>
<li>Scheduled report exports</li> <li>Scheduled report exports</li>
@@ -15,7 +15,7 @@
({{ $activeBranchCount }} active). ({{ $activeBranchCount }} active).
@endif @endif
@else @else
<span class="font-medium text-slate-900">Free</span> one branch, one kiosk, core check-in and badges. Host alerts billed after {{ number_format($freeEmailAllowance) }} free emails per month. <span class="font-medium text-slate-900">Free</span> one branch (setup only), one kiosk, owner-only access. Multi-branch and team require Pro. Host alerts billed after {{ number_format($freeEmailAllowance) }} free emails per month.
@endif @endif
</p> </p>
@if (! $hasPaidPlan) @if (! $hasPaidPlan)
@@ -47,6 +47,45 @@
</x-settings.card> </x-settings.card>
@endif @endif
@if ($canViewBranches || $canViewTeam)
<div class="grid gap-4 sm:grid-cols-2">
@if ($canViewBranches)
<a href="{{ route('frontdesk.branches.index') }}"
class="group rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 hover:shadow-md">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-slate-900 group-hover:text-indigo-700">Branches</p>
<p class="mt-1 text-sm text-slate-500">Locations, buildings, and reception desks.</p>
</div>
@if (! $hasBranchesFeature)
<span class="shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Pro</span>
@endif
</div>
<p class="mt-4 text-xs font-medium text-indigo-600">
{{ $hasBranchesFeature ? 'Manage branches →' : 'Upgrade to unlock →' }}
</p>
</a>
@endif
@if ($canViewTeam)
<a href="{{ route('frontdesk.members.index') }}"
class="group rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 hover:shadow-md">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-slate-900 group-hover:text-indigo-700">Team</p>
<p class="mt-1 text-sm text-slate-500">Invite colleagues and assign Frontdesk roles.</p>
</div>
@if (! $hasTeamFeature)
<span class="shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Pro</span>
@endif
</div>
<p class="mt-4 text-xs font-medium text-indigo-600">
{{ $hasTeamFeature ? 'Manage team →' : 'Upgrade to unlock →' }}
</p>
</a>
@endif
</div>
@endif
<form method="POST" action="{{ route('frontdesk.settings.update') }}" enctype="multipart/form-data" class="space-y-6"> <form method="POST" action="{{ route('frontdesk.settings.update') }}" enctype="multipart/form-data" class="space-y-6">
@csrf @method('PUT') @csrf @method('PUT')
@@ -0,0 +1,21 @@
<x-app-layout :title="$title">
<div class="mx-auto max-w-lg">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-400">Settings</p>
<h1 class="mt-1 text-xl font-semibold text-slate-900">{{ $title }}</h1>
<p class="mt-2 text-sm text-slate-600">{{ $description }}</p>
<div class="mt-6 rounded-2xl border border-indigo-200 bg-indigo-50 p-6">
<h2 class="font-semibold text-indigo-900">Upgrade to Pro</h2>
<p class="mt-2 text-sm text-indigo-800">
GHS {{ number_format($proPriceMinor / 100, 0) }} / branch / month from your Ladill wallet
multi-branch locations, team roles, integrations, and more.
</p>
<a href="{{ route('frontdesk.pro.index') }}" class="btn-primary mt-4 inline-flex">View Pro plans</a>
<p class="mt-3 text-xs text-indigo-700">
<a href="{{ route('frontdesk.wallet') }}" class="underline">Top up your wallet</a> if you need more balance.
</p>
</div>
<a href="{{ route('frontdesk.settings') }}" class="mt-6 inline-block text-sm text-slate-600 hover:text-slate-900">&larr; Back to settings</a>
</div>
</x-app-layout>
+10 -22
View File
@@ -98,15 +98,13 @@
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />']; 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />'];
} }
$adminNav = []; $settingsActive = request()->routeIs('frontdesk.settings')
if ($permissions->can($member, 'admin.branches.view')) { || request()->routeIs('frontdesk.branches.*')
$adminNav[] = ['name' => 'Branches', 'route' => route('frontdesk.branches.index'), 'active' => request()->routeIs('frontdesk.branches.*') || request()->routeIs('frontdesk.buildings.*') || request()->routeIs('frontdesk.desks.*'), || request()->routeIs('frontdesk.buildings.*')
'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" />']; || request()->routeIs('frontdesk.desks.*')
} || request()->routeIs('frontdesk.members.*')
if ($permissions->can($member, 'admin.members.view')) { || request()->routeIs('frontdesk.integrations.*')
$adminNav[] = ['name' => 'Team', 'route' => route('frontdesk.members.index'), 'active' => request()->routeIs('frontdesk.members.*'), || request()->routeIs('frontdesk.settings.badge*');
'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" />'];
}
@endphp @endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4"> <nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@foreach($nav as $item) @foreach($nav as $item)
@@ -116,7 +114,7 @@
</a> </a>
@endforeach @endforeach
@if (count($adminNav) > 0 || count($complianceNav) > 0) @if (count($complianceNav) > 0)
<p class="mb-1 mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Compliance</p> <p class="mb-1 mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Compliance</p>
@foreach ($complianceNav as $item) @foreach ($complianceNav as $item)
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}"> <a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
@@ -125,22 +123,12 @@
</a> </a>
@endforeach @endforeach
@endif @endif
@if (count($adminNav) > 0)
<p class="mb-1 mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Administration</p>
@foreach ($adminNav as $item)
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="{{ $item['iconSizeClass'] ?? 'h-[18px] w-[18px]' }} shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" viewBox="{{ $item['iconViewBox'] ?? '0 0 24 24' }}" fill="none" stroke="currentColor" stroke-width="{{ $item['iconStrokeWidth'] ?? 1.5 }}">{!! $item['icon'] !!}</svg>
<span>{{ $item['name'] }}</span>
</a>
@endforeach
@endif
</nav> </nav>
<div class="shrink-0 border-t border-slate-200 px-3 py-3"> <div class="shrink-0 border-t border-slate-200 px-3 py-3">
<a href="{{ route('frontdesk.settings') }}" <a href="{{ route('frontdesk.settings') }}"
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ request()->routeIs('frontdesk.settings') && ! request()->routeIs('frontdesk.pro.*') ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}"> class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $settingsActive ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 {{ request()->routeIs('frontdesk.settings') && ! request()->routeIs('frontdesk.pro.*') ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-[18px] w-[18px] shrink-0 {{ $settingsActive ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.241.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.281Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.241.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.281Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg> </svg>
<span class="flex-1 truncate">Settings</span> <span class="flex-1 truncate">Settings</span>
+20 -15
View File
@@ -191,24 +191,29 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
// Legacy alias — defaults branches to active count when omitted. // Legacy alias — defaults branches to active count when omitted.
Route::post('/settings/upgrade', [ProController::class, 'subscribe'])->name('frontdesk.settings.upgrade'); Route::post('/settings/upgrade', [ProController::class, 'subscribe'])->name('frontdesk.settings.upgrade');
Route::get('/branches', [BranchController::class, 'index'])->name('frontdesk.branches.index'); // Branches & Team live under Settings and require a paid plan.
Route::get('/branches/create', [BranchController::class, 'create'])->name('frontdesk.branches.create'); Route::get('/settings/branches', [BranchController::class, 'index'])->name('frontdesk.branches.index');
Route::post('/branches', [BranchController::class, 'store'])->name('frontdesk.branches.store'); Route::get('/settings/branches/create', [BranchController::class, 'create'])->name('frontdesk.branches.create');
Route::get('/branches/{branch}/edit', [BranchController::class, 'edit'])->name('frontdesk.branches.edit'); Route::post('/settings/branches', [BranchController::class, 'store'])->name('frontdesk.branches.store');
Route::put('/branches/{branch}', [BranchController::class, 'update'])->name('frontdesk.branches.update'); Route::get('/settings/branches/{branch}/edit', [BranchController::class, 'edit'])->name('frontdesk.branches.edit');
Route::put('/settings/branches/{branch}', [BranchController::class, 'update'])->name('frontdesk.branches.update');
Route::get('/branches/{branch}/buildings', [BuildingController::class, 'index'])->name('frontdesk.buildings.index'); Route::get('/settings/branches/{branch}/buildings', [BuildingController::class, 'index'])->name('frontdesk.buildings.index');
Route::post('/branches/{branch}/buildings', [BuildingController::class, 'store'])->name('frontdesk.buildings.store'); Route::post('/settings/branches/{branch}/buildings', [BuildingController::class, 'store'])->name('frontdesk.buildings.store');
Route::delete('/branches/{branch}/buildings/{building}', [BuildingController::class, 'destroy'])->name('frontdesk.buildings.destroy'); Route::delete('/settings/branches/{branch}/buildings/{building}', [BuildingController::class, 'destroy'])->name('frontdesk.buildings.destroy');
Route::get('/buildings/{building}/desks', [ReceptionDeskController::class, 'index'])->name('frontdesk.desks.index'); Route::get('/settings/buildings/{building}/desks', [ReceptionDeskController::class, 'index'])->name('frontdesk.desks.index');
Route::post('/buildings/{building}/desks', [ReceptionDeskController::class, 'store'])->name('frontdesk.desks.store'); Route::post('/settings/buildings/{building}/desks', [ReceptionDeskController::class, 'store'])->name('frontdesk.desks.store');
Route::delete('/buildings/{building}/desks/{desk}', [ReceptionDeskController::class, 'destroy'])->name('frontdesk.desks.destroy'); Route::delete('/settings/buildings/{building}/desks/{desk}', [ReceptionDeskController::class, 'destroy'])->name('frontdesk.desks.destroy');
Route::get('/members', [MemberController::class, 'index'])->name('frontdesk.members.index'); Route::get('/settings/team', [MemberController::class, 'index'])->name('frontdesk.members.index');
Route::get('/members/create', [MemberController::class, 'create'])->name('frontdesk.members.create'); Route::get('/settings/team/create', [MemberController::class, 'create'])->name('frontdesk.members.create');
Route::post('/members', [MemberController::class, 'store'])->name('frontdesk.members.store'); Route::post('/settings/team', [MemberController::class, 'store'])->name('frontdesk.members.store');
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('frontdesk.members.destroy'); Route::delete('/settings/team/{member}', [MemberController::class, 'destroy'])->name('frontdesk.members.destroy');
// Legacy path redirects.
Route::redirect('/branches', '/settings/branches');
Route::redirect('/members', '/settings/team');
Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('frontdesk.wallet'); Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('frontdesk.wallet');
Route::get('/wallet/balance', [WalletController::class, 'balance'])->name('frontdesk.wallet.balance'); Route::get('/wallet/balance', [WalletController::class, 'balance'])->name('frontdesk.wallet.balance');
+58 -4
View File
@@ -63,8 +63,16 @@ class FrontdeskPhase2Test extends TestCase
$this->assertDatabaseHas('frontdesk_members', ['role' => 'org_admin']); $this->assertDatabaseHas('frontdesk_members', ['role' => 'org_admin']);
} }
public function test_org_admin_can_create_branch(): void public function test_org_admin_can_create_branch_on_pro(): void
{ {
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'pro',
'billed_branches' => 3,
'plan_expires_at' => now()->addMonth()->toIso8601String(),
]),
]);
$this->actingAs($this->admin) $this->actingAs($this->admin)
->post(route('frontdesk.branches.store'), [ ->post(route('frontdesk.branches.store'), [
'name' => 'East Wing', 'name' => 'East Wing',
@@ -75,8 +83,31 @@ class FrontdeskPhase2Test extends TestCase
$this->assertDatabaseHas('frontdesk_branches', ['name' => 'East Wing']); $this->assertDatabaseHas('frontdesk_branches', ['name' => 'East Wing']);
} }
public function test_org_admin_can_add_team_member(): void public function test_free_plan_cannot_manage_branches(): void
{ {
$this->actingAs($this->admin)
->get(route('frontdesk.branches.index'))
->assertOk()
->assertSee('Upgrade to Pro');
$this->actingAs($this->admin)
->post(route('frontdesk.branches.store'), [
'name' => 'East Wing',
])
->assertRedirect(route('frontdesk.pro.index'))
->assertSessionHas('error');
}
public function test_org_admin_can_add_team_member_on_pro(): void
{
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'pro',
'billed_branches' => 1,
'plan_expires_at' => now()->addMonth()->toIso8601String(),
]),
]);
$branch = Branch::create([ $branch = Branch::create([
'owner_ref' => $this->admin->public_id, 'owner_ref' => $this->admin->public_id,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -84,20 +115,43 @@ class FrontdeskPhase2Test extends TestCase
'is_active' => true, 'is_active' => true,
]); ]);
$this->mock(\App\Services\Identity\IdentityTeamClient::class, function ($mock) {
$mock->shouldReceive('inviteAppMember')->once();
});
$this->actingAs($this->admin) $this->actingAs($this->admin)
->post(route('frontdesk.members.store'), [ ->post(route('frontdesk.members.store'), [
'user_ref' => 'receptionist-001', 'email' => 'receptionist@example.com',
'role' => 'receptionist', 'role' => 'receptionist',
'branch_id' => $branch->id, 'branch_id' => $branch->id,
]) ])
->assertRedirect(route('frontdesk.members.index')); ->assertRedirect(route('frontdesk.members.index'));
$this->assertDatabaseHas('frontdesk_members', [ $this->assertDatabaseHas('frontdesk_members', [
'user_ref' => 'receptionist-001', 'user_ref' => 'receptionist@example.com',
'role' => 'receptionist', 'role' => 'receptionist',
]); ]);
} }
public function test_free_plan_cannot_manage_team(): void
{
$this->actingAs($this->admin)
->get(route('frontdesk.members.index'))
->assertOk()
->assertSee('Upgrade to Pro');
}
public function test_settings_links_to_branches_and_team(): void
{
$this->actingAs($this->admin)
->get(route('frontdesk.settings'))
->assertOk()
->assertSee('Branches')
->assertSee('Team')
->assertSee(route('frontdesk.branches.index'), false)
->assertSee(route('frontdesk.members.index'), false);
}
public function test_receptionist_cannot_manage_branches(): void public function test_receptionist_cannot_manage_branches(): void
{ {
$receptionist = User::create([ $receptionist = User::create([
+23
View File
@@ -459,4 +459,27 @@ class FrontdeskProTest extends TestCase
$this->assertDatabaseHas('frontdesk_branches', ['name' => 'Second Branch']); $this->assertDatabaseHas('frontdesk_branches', ['name' => 'Second Branch']);
} }
public function test_pro_can_open_branches_and_team_settings(): void
{
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'pro',
'billed_branches' => 1,
'plan_expires_at' => now()->addMonth()->toIso8601String(),
]),
]);
$this->actingAs($this->owner)
->get(route('frontdesk.branches.index'))
->assertOk()
->assertSee('Branches')
->assertDontSee('Upgrade to Pro');
$this->actingAs($this->owner)
->get(route('frontdesk.members.index'))
->assertOk()
->assertSee('Team')
->assertDontSee('Upgrade to Pro');
}
} }