Move Branches and Team into Settings as Pro features.
Deploy Ladill Frontdesk / deploy (push) Successful in 48s
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:
@@ -3,19 +3,36 @@
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Services\Frontdesk\PlanService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BranchController extends Controller
|
||||
{
|
||||
use RequiresPlanFeature;
|
||||
use ScopesToAccount;
|
||||
|
||||
private const FEATURE = 'branches';
|
||||
|
||||
private const UPGRADE_MESSAGE = 'Multi-branch management requires Frontdesk Pro.';
|
||||
|
||||
public function index(Request $request): 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);
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
@@ -36,14 +53,29 @@ class BranchController extends Controller
|
||||
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 Frontdesk Pro.',
|
||||
)) {
|
||||
return $upgrade;
|
||||
}
|
||||
|
||||
return view('frontdesk.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);
|
||||
$plans = app(\App\Services\Frontdesk\PlanService::class);
|
||||
$plans = app(PlanService::class);
|
||||
|
||||
$currentCount = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
@@ -75,6 +107,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 Frontdesk Pro.',
|
||||
)) {
|
||||
return $upgrade;
|
||||
}
|
||||
|
||||
return view('frontdesk.admin.branches.edit', compact('branch'));
|
||||
}
|
||||
|
||||
@@ -83,6 +124,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'],
|
||||
@@ -93,7 +138,7 @@ class BranchController extends Controller
|
||||
|
||||
$activating = $request->boolean('is_active') && ! $branch->is_active;
|
||||
if ($activating) {
|
||||
$plans = app(\App\Services\Frontdesk\PlanService::class);
|
||||
$plans = app(PlanService::class);
|
||||
$organization = $this->organization($request);
|
||||
if (! $plans->canActivateBranch($organization)) {
|
||||
return back()->withInput()->with('error', $plans->branchLimitMessage($organization));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Building;
|
||||
@@ -12,13 +13,27 @@ use Illuminate\View\View;
|
||||
|
||||
class BuildingController extends Controller
|
||||
{
|
||||
use RequiresPlanFeature;
|
||||
use ScopesToAccount;
|
||||
|
||||
private const FEATURE = 'branches';
|
||||
|
||||
private const UPGRADE_MESSAGE = 'Multi-branch management requires Frontdesk Pro.';
|
||||
|
||||
public function index(Request $request, Branch $branch): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.view');
|
||||
$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();
|
||||
|
||||
return view('frontdesk.admin.buildings.index', compact('branch', 'buildings'));
|
||||
@@ -29,6 +44,10 @@ class BuildingController 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'],
|
||||
'floor_count' => ['nullable', 'string', 'max:20'],
|
||||
@@ -49,6 +68,10 @@ class BuildingController extends Controller
|
||||
$this->authorizeOwner($request, $building);
|
||||
abort_unless($building->branch_id === $branch->id, 404);
|
||||
|
||||
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
|
||||
return $deny;
|
||||
}
|
||||
|
||||
$building->delete();
|
||||
|
||||
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;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Member;
|
||||
@@ -13,11 +14,26 @@ use Illuminate\View\View;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
use RequiresPlanFeature;
|
||||
use ScopesToAccount;
|
||||
|
||||
private const FEATURE = 'team';
|
||||
|
||||
private const UPGRADE_MESSAGE = 'Team management requires Frontdesk 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 Frontdesk Pro.',
|
||||
)) {
|
||||
return $upgrade;
|
||||
}
|
||||
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$members = Member::owned($this->ownerRef($request))
|
||||
@@ -45,6 +61,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 team members with Frontdesk Pro.',
|
||||
)) {
|
||||
return $upgrade;
|
||||
}
|
||||
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
@@ -71,6 +97,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);
|
||||
|
||||
@@ -119,6 +150,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.');
|
||||
|
||||
$member->delete();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Building;
|
||||
use App\Models\ReceptionDesk;
|
||||
@@ -12,13 +13,27 @@ use Illuminate\View\View;
|
||||
|
||||
class ReceptionDeskController extends Controller
|
||||
{
|
||||
use RequiresPlanFeature;
|
||||
use ScopesToAccount;
|
||||
|
||||
private const FEATURE = 'branches';
|
||||
|
||||
private const UPGRADE_MESSAGE = 'Multi-branch management requires Frontdesk Pro.';
|
||||
|
||||
public function index(Request $request, Building $building): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.desks.view');
|
||||
$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();
|
||||
|
||||
return view('frontdesk.admin.desks.index', compact('building', 'desks'));
|
||||
@@ -29,6 +44,10 @@ class ReceptionDeskController extends Controller
|
||||
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||
$this->authorizeOwner($request, $building);
|
||||
|
||||
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
|
||||
return $deny;
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'location' => ['nullable', 'string', 'max:255'],
|
||||
@@ -50,6 +69,10 @@ class ReceptionDeskController extends Controller
|
||||
$this->authorizeOwner($request, $desk);
|
||||
abort_unless($desk->building_id === $building->id, 404);
|
||||
|
||||
if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) {
|
||||
return $deny;
|
||||
}
|
||||
|
||||
$desk->delete();
|
||||
|
||||
return back()->with('success', 'Reception desk removed.');
|
||||
|
||||
@@ -32,6 +32,8 @@ class SettingsController extends Controller
|
||||
$monthlyUsage = $usage->forOrganization($organization);
|
||||
$plan = $plans->plan($organization);
|
||||
$freeEmailAllowance = $plans->freeEmailsPerMonth($organization);
|
||||
$member = $this->member($request);
|
||||
$permissions = app(FrontdeskPermissions::class);
|
||||
|
||||
return view('frontdesk.settings.edit', [
|
||||
'organization' => $organization,
|
||||
@@ -45,6 +47,10 @@ class SettingsController extends Controller
|
||||
'isPro' => $plans->isPro($organization),
|
||||
'isEnterprise' => $plans->isEnterprise($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),
|
||||
'activeBranchCount' => $plans->activeBranchCount($organization),
|
||||
'proPriceMinor' => $plans->proPricePerBranchMinor(),
|
||||
|
||||
@@ -160,6 +160,8 @@ return [
|
||||
'hosts',
|
||||
'badges',
|
||||
'watchlist',
|
||||
'branches',
|
||||
'team',
|
||||
'integrations',
|
||||
'webhooks',
|
||||
'scheduled_reports',
|
||||
@@ -178,6 +180,8 @@ return [
|
||||
'hosts',
|
||||
'badges',
|
||||
'watchlist',
|
||||
'branches',
|
||||
'team',
|
||||
'integrations',
|
||||
'webhooks',
|
||||
'scheduled_reports',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<x-app-layout title="Add branch">
|
||||
<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">← 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">
|
||||
@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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<x-app-layout title="Edit branch">
|
||||
<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">← 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">
|
||||
@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>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
<x-app-layout title="Branches">
|
||||
<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">← Settings</a>
|
||||
<x-frontdesk.page-hero
|
||||
badge="Locations · Buildings · Reception desks"
|
||||
badge="Settings · Locations · Buildings"
|
||||
title="Branches"
|
||||
description="Organize your sites, buildings, and reception desks so visits and devices stay scoped to the right location."
|
||||
:stats="[
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<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>
|
||||
<a href="{{ route('frontdesk.members.index') }}" class="text-sm text-slate-500 hover:text-slate-800">← 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">
|
||||
@csrf
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<x-app-layout title="Team">
|
||||
<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">← Settings</a>
|
||||
<x-frontdesk.page-hero
|
||||
badge="Roles · Access · Branch scope"
|
||||
badge="Settings · Roles · Access"
|
||||
title="Team"
|
||||
description="Invite colleagues, assign Frontdesk roles, and limit branch access for reception and security staff."
|
||||
: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' : '' }}">
|
||||
<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-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">
|
||||
<li>Core check-in & badges</li>
|
||||
<li>100 host emails / month</li>
|
||||
<li>Watchlist & hosts</li>
|
||||
<li class="text-slate-400">Multi-branch & team (Pro)</li>
|
||||
</ul>
|
||||
@if ($planKey === 'free')
|
||||
<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
|
||||
</p>
|
||||
<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>Webhooks & iCal feeds</li>
|
||||
<li>Scheduled report exports</li>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
({{ $activeBranchCount }} active).
|
||||
@endif
|
||||
@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
|
||||
</p>
|
||||
@if (! $hasPaidPlan)
|
||||
@@ -47,6 +47,45 @@
|
||||
</x-settings.card>
|
||||
@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">
|
||||
@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">← Back to settings</a>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -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" />'];
|
||||
}
|
||||
|
||||
$adminNav = [];
|
||||
if ($permissions->can($member, 'admin.branches.view')) {
|
||||
$adminNav[] = ['name' => 'Branches', 'route' => route('frontdesk.branches.index'), 'active' => request()->routeIs('frontdesk.branches.*') || request()->routeIs('frontdesk.buildings.*') || request()->routeIs('frontdesk.desks.*'),
|
||||
'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.members.view')) {
|
||||
$adminNav[] = ['name' => 'Team', 'route' => route('frontdesk.members.index'), 'active' => request()->routeIs('frontdesk.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" />'];
|
||||
}
|
||||
$settingsActive = request()->routeIs('frontdesk.settings')
|
||||
|| request()->routeIs('frontdesk.branches.*')
|
||||
|| request()->routeIs('frontdesk.buildings.*')
|
||||
|| request()->routeIs('frontdesk.desks.*')
|
||||
|| request()->routeIs('frontdesk.members.*')
|
||||
|| request()->routeIs('frontdesk.integrations.*')
|
||||
|| request()->routeIs('frontdesk.settings.badge*');
|
||||
@endphp
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
@foreach($nav as $item)
|
||||
@@ -116,7 +114,7 @@
|
||||
</a>
|
||||
@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>
|
||||
@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' }}">
|
||||
@@ -125,22 +123,12 @@
|
||||
</a>
|
||||
@endforeach
|
||||
@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>
|
||||
|
||||
<div class="shrink-0 border-t border-slate-200 px-3 py-3">
|
||||
<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' }}">
|
||||
<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">
|
||||
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 {{ $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" />
|
||||
</svg>
|
||||
<span class="flex-1 truncate">Settings</span>
|
||||
|
||||
+20
-15
@@ -191,24 +191,29 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
// Legacy alias — defaults branches to active count when omitted.
|
||||
Route::post('/settings/upgrade', [ProController::class, 'subscribe'])->name('frontdesk.settings.upgrade');
|
||||
|
||||
Route::get('/branches', [BranchController::class, 'index'])->name('frontdesk.branches.index');
|
||||
Route::get('/branches/create', [BranchController::class, 'create'])->name('frontdesk.branches.create');
|
||||
Route::post('/branches', [BranchController::class, 'store'])->name('frontdesk.branches.store');
|
||||
Route::get('/branches/{branch}/edit', [BranchController::class, 'edit'])->name('frontdesk.branches.edit');
|
||||
Route::put('/branches/{branch}', [BranchController::class, 'update'])->name('frontdesk.branches.update');
|
||||
// Branches & Team live under Settings and require a paid plan.
|
||||
Route::get('/settings/branches', [BranchController::class, 'index'])->name('frontdesk.branches.index');
|
||||
Route::get('/settings/branches/create', [BranchController::class, 'create'])->name('frontdesk.branches.create');
|
||||
Route::post('/settings/branches', [BranchController::class, 'store'])->name('frontdesk.branches.store');
|
||||
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::post('/branches/{branch}/buildings', [BuildingController::class, 'store'])->name('frontdesk.buildings.store');
|
||||
Route::delete('/branches/{branch}/buildings/{building}', [BuildingController::class, 'destroy'])->name('frontdesk.buildings.destroy');
|
||||
Route::get('/settings/branches/{branch}/buildings', [BuildingController::class, 'index'])->name('frontdesk.buildings.index');
|
||||
Route::post('/settings/branches/{branch}/buildings', [BuildingController::class, 'store'])->name('frontdesk.buildings.store');
|
||||
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::post('/buildings/{building}/desks', [ReceptionDeskController::class, 'store'])->name('frontdesk.desks.store');
|
||||
Route::delete('/buildings/{building}/desks/{desk}', [ReceptionDeskController::class, 'destroy'])->name('frontdesk.desks.destroy');
|
||||
Route::get('/settings/buildings/{building}/desks', [ReceptionDeskController::class, 'index'])->name('frontdesk.desks.index');
|
||||
Route::post('/settings/buildings/{building}/desks', [ReceptionDeskController::class, 'store'])->name('frontdesk.desks.store');
|
||||
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('/members/create', [MemberController::class, 'create'])->name('frontdesk.members.create');
|
||||
Route::post('/members', [MemberController::class, 'store'])->name('frontdesk.members.store');
|
||||
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('frontdesk.members.destroy');
|
||||
Route::get('/settings/team', [MemberController::class, 'index'])->name('frontdesk.members.index');
|
||||
Route::get('/settings/team/create', [MemberController::class, 'create'])->name('frontdesk.members.create');
|
||||
Route::post('/settings/team', [MemberController::class, 'store'])->name('frontdesk.members.store');
|
||||
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/balance', [WalletController::class, 'balance'])->name('frontdesk.wallet.balance');
|
||||
|
||||
@@ -63,8 +63,16 @@ class FrontdeskPhase2Test extends TestCase
|
||||
$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)
|
||||
->post(route('frontdesk.branches.store'), [
|
||||
'name' => 'East Wing',
|
||||
@@ -75,8 +83,31 @@ class FrontdeskPhase2Test extends TestCase
|
||||
$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([
|
||||
'owner_ref' => $this->admin->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
@@ -84,20 +115,43 @@ class FrontdeskPhase2Test extends TestCase
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->mock(\App\Services\Identity\IdentityTeamClient::class, function ($mock) {
|
||||
$mock->shouldReceive('inviteAppMember')->once();
|
||||
});
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->post(route('frontdesk.members.store'), [
|
||||
'user_ref' => 'receptionist-001',
|
||||
'email' => 'receptionist@example.com',
|
||||
'role' => 'receptionist',
|
||||
'branch_id' => $branch->id,
|
||||
])
|
||||
->assertRedirect(route('frontdesk.members.index'));
|
||||
|
||||
$this->assertDatabaseHas('frontdesk_members', [
|
||||
'user_ref' => 'receptionist-001',
|
||||
'user_ref' => 'receptionist@example.com',
|
||||
'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
|
||||
{
|
||||
$receptionist = User::create([
|
||||
|
||||
@@ -459,4 +459,27 @@ class FrontdeskProTest extends TestCase
|
||||
|
||||
$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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user