diff --git a/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php b/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php index f04786ec..cf344b19 100644 --- a/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php +++ b/Modules/Platform/app/Http/Controllers/PlatformDashboardController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use Modules\Platform\app\Models\Company; use Modules\Platform\app\Models\LicenseKey; use Modules\Platform\app\Models\Payment; +use Modules\Platform\app\Models\PlatformPrice; use Modules\Warehouse\app\Models\Warehouse; class PlatformDashboardController extends Controller @@ -21,6 +22,7 @@ class PlatformDashboardController extends Controller 'licensesCount' => LicenseKey::count(), 'paymentsTotal' => Payment::where('status', 'paid')->sum('amount'), 'warehousesCount' => Warehouse::count(), + 'pricesCount' => PlatformPrice::count(), 'companies' => Company::withCount(['users', 'warehouses', 'licenseKeys'])->latest()->limit(8)->get(), ]); } diff --git a/Modules/Platform/app/Http/Controllers/PlatformPriceController.php b/Modules/Platform/app/Http/Controllers/PlatformPriceController.php new file mode 100644 index 00000000..aa6102c0 --- /dev/null +++ b/Modules/Platform/app/Http/Controllers/PlatformPriceController.php @@ -0,0 +1,117 @@ + PlatformPrice::ordered()->get(), + ]); + } + + /** + * Show the create price form. + */ + public function create(): View + { + return view('platform::prices.create', [ + 'price' => new PlatformPrice([ + 'currency' => 'gbp', + 'sort_order' => 40, + 'is_active' => true, + 'features' => [], + ]), + ]); + } + + /** + * Store a new platform price. + */ + public function store(Request $request): RedirectResponse + { + PlatformPrice::create($this->validatedPriceData($request)); + + return redirect()->route('platform.prices.index')->with('status', 'Price created.'); + } + + /** + * Show the edit price form. + */ + public function edit(PlatformPrice $price): View + { + return view('platform::prices.edit', [ + 'price' => $price, + ]); + } + + /** + * Update an existing platform price. + */ + public function update(Request $request, PlatformPrice $price): RedirectResponse + { + $price->update($this->validatedPriceData($request, $price)); + + return redirect()->route('platform.prices.index')->with('status', 'Price updated.'); + } + + /** + * Delete a platform price. + */ + public function destroy(PlatformPrice $price): RedirectResponse + { + $price->delete(); + + return redirect()->route('platform.prices.index')->with('status', 'Price deleted.'); + } + + /** + * Validate and normalise price request data. + * + * @return array + */ + private function validatedPriceData(Request $request, ?PlatformPrice $price = null): array + { + $data = $request->validate([ + 'code' => ['nullable', 'string', 'max:40', 'unique:platform_prices,code,' . ($price?->id ?? 'NULL')], + 'name' => ['required', 'string', 'max:255'], + 'strapline' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'monthly_amount' => ['required', 'numeric', 'min:0', 'max:999999.99'], + 'annual_amount' => ['nullable', 'numeric', 'min:0', 'max:999999.99'], + 'currency' => ['required', 'string', 'size:3'], + 'badge_label' => ['nullable', 'string', 'max:60'], + 'features' => ['nullable', 'string', 'max:4000'], + 'sort_order' => ['required', 'integer', 'min:0', 'max:65535'], + 'is_featured' => ['nullable', 'boolean'], + 'is_active' => ['nullable', 'boolean'], + ]); + + $data['code'] = Str::slug($data['code'] ?: $data['name']); + $data['monthly_amount'] = (int) round(((float) $data['monthly_amount']) * 100); + $data['annual_amount'] = $data['annual_amount'] === null || $data['annual_amount'] === '' + ? null + : (int) round(((float) $data['annual_amount']) * 100); + $data['currency'] = strtolower($data['currency']); + $data['features'] = collect(preg_split('/\r\n|\r|\n/', (string) ($data['features'] ?? ''))) + ->map(fn (string $feature): string => trim($feature)) + ->filter() + ->values() + ->all(); + $data['is_featured'] = $request->boolean('is_featured'); + $data['is_active'] = $request->boolean('is_active'); + + return $data; + } +} diff --git a/Modules/Platform/app/Models/PlatformPrice.php b/Modules/Platform/app/Models/PlatformPrice.php new file mode 100644 index 00000000..b9e75206 --- /dev/null +++ b/Modules/Platform/app/Models/PlatformPrice.php @@ -0,0 +1,80 @@ + 'array', + 'monthly_amount' => 'integer', + 'annual_amount' => 'integer', + 'sort_order' => 'integer', + 'is_featured' => 'boolean', + 'is_active' => 'boolean', + ]; + + /** + * Scope prices into display order. + */ + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('sort_order')->orderBy('name'); + } + + /** + * Return the display currency symbol. + */ + public function currencySymbol(): string + { + return match (strtolower($this->currency)) { + 'gbp' => '£', + 'eur' => '€', + 'usd' => '$', + default => strtoupper($this->currency) . ' ', + }; + } + + /** + * Format a stored minor-unit amount for display. + */ + public function formatAmount(?int $amount): string + { + if ($amount === null) { + return '—'; + } + + return $this->currencySymbol() . number_format($amount / 100, 2); + } + + /** + * Return normalised feature rows. + * + * @return array + */ + public function featureRows(): array + { + return collect($this->features ?? []) + ->filter(fn ($feature): bool => is_string($feature) && trim($feature) !== '') + ->map(fn (string $feature): string => trim($feature)) + ->values() + ->all(); + } +} diff --git a/Modules/Platform/database/migrations/2026_06_11_093500_create_platform_prices_table.php b/Modules/Platform/database/migrations/2026_06_11_093500_create_platform_prices_table.php new file mode 100644 index 00000000..ecdcb358 --- /dev/null +++ b/Modules/Platform/database/migrations/2026_06_11_093500_create_platform_prices_table.php @@ -0,0 +1,91 @@ +id(); + $table->string('code', 40)->unique(); + $table->string('name'); + $table->string('strapline')->nullable(); + $table->text('description')->nullable(); + $table->unsignedInteger('monthly_amount')->default(0); + $table->unsignedInteger('annual_amount')->nullable(); + $table->string('currency', 3)->default('gbp'); + $table->string('badge_label')->nullable(); + $table->json('features')->nullable(); + $table->unsignedSmallInteger('sort_order')->default(0)->index(); + $table->boolean('is_featured')->default(false)->index(); + $table->boolean('is_active')->default(true)->index(); + $table->timestamps(); + }); + + DB::table('platform_prices')->insert([ + [ + 'code' => 'basic', + 'name' => 'Basic', + 'strapline' => 'Core warehouse visibility', + 'description' => 'Essential WMS tools for small warehouse operations and controlled stock visibility.', + 'monthly_amount' => 4900, + 'annual_amount' => 49000, + 'currency' => 'gbp', + 'badge_label' => 'Starter', + 'features' => json_encode(['Warehouse dashboard', 'Inventory overview', 'Basic audit trail', 'Email support']), + 'sort_order' => 10, + 'is_featured' => false, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'code' => 'advanced', + 'name' => 'Advanced', + 'strapline' => 'Operational control suite', + 'description' => 'Expanded controls for teams that need replenishment, reporting and integration monitoring.', + 'monthly_amount' => 9900, + 'annual_amount' => 99000, + 'currency' => 'gbp', + 'badge_label' => 'Popular', + 'features' => json_encode(['Everything in Basic', 'Replenishment workflows', 'Reporting workspace', 'M3 integration status', 'Priority support']), + 'sort_order' => 20, + 'is_featured' => true, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'code' => 'pro', + 'name' => 'Pro', + 'strapline' => 'Enterprise-grade administration', + 'description' => 'Full platform administration for regulated warehouse, carrier and document operations.', + 'monthly_amount' => 19900, + 'annual_amount' => 199000, + 'currency' => 'gbp', + 'badge_label' => 'Enterprise', + 'features' => json_encode(['Everything in Advanced', 'Multi-company administration', 'Security and audit controls', 'Document archive', 'Carrier management', 'Dedicated support']), + 'sort_order' => 30, + 'is_featured' => false, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('platform_prices'); + } +}; diff --git a/Modules/Platform/resources/views/dashboard/index.blade.php b/Modules/Platform/resources/views/dashboard/index.blade.php index 200f6a28..ae82aedf 100644 --- a/Modules/Platform/resources/views/dashboard/index.blade.php +++ b/Modules/Platform/resources/views/dashboard/index.blade.php @@ -1,7 +1,30 @@
+
+
+
+

MPBA WMS

+

Platform administration

+

Manage tenant companies, licences, payments, warehouses and internal API operations from one resilient control plane.

+
+
+
+

System status

+

+ + OK +

+
+
+

Paid £

+

{{ number_format($paymentsTotal / 100, 2) }}

+
+
+
+
+
- @foreach ([['Companies', $companiesCount], ['Active', $activeCompaniesCount], ['Warehouses', $warehousesCount], ['Licences', $licensesCount]] as [$label, $value]) + @foreach ([['Companies', $companiesCount], ['Active', $activeCompaniesCount], ['Warehouses', $warehousesCount], ['Prices', $pricesCount], ['Licences', $licensesCount]] as [$label, $value])

{{ $label }}

{{ $value }}

@@ -15,7 +38,7 @@

Recent companies

Tenant isolation is enforced by company ownership on every warehouse record.

- Manage companies +
diff --git a/Modules/Platform/resources/views/prices/_form.blade.php b/Modules/Platform/resources/views/prices/_form.blade.php new file mode 100644 index 00000000..85574ada --- /dev/null +++ b/Modules/Platform/resources/views/prices/_form.blade.php @@ -0,0 +1,77 @@ +@php + $featuresText = old('features', implode("\n", $price->featureRows())); +@endphp + +
+
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + + @error('code')

{{ $message }}

@enderror +
+ +
+ + + @error('strapline')

{{ $message }}

@enderror +
+ +
+ + + @error('badge_label')

{{ $message }}

@enderror +
+ +
+ + + @error('monthly_amount')

{{ $message }}

@enderror +
+ +
+ + + @error('annual_amount')

{{ $message }}

@enderror +
+ +
+ + + @error('currency')

{{ $message }}

@enderror +
+ +
+ + + @error('sort_order')

{{ $message }}

@enderror +
+ +
+ + + @error('description')

{{ $message }}

@enderror +
+ +
+ + + @error('features')

{{ $message }}

@enderror +
+ +
+ + + +
+
diff --git a/Modules/Platform/resources/views/prices/create.blade.php b/Modules/Platform/resources/views/prices/create.blade.php new file mode 100644 index 00000000..e076c88d --- /dev/null +++ b/Modules/Platform/resources/views/prices/create.blade.php @@ -0,0 +1,26 @@ + +
+
+
+
+

Administration

+

Create price

+

Add a new platform pricing card with billing values, feature bullets and status controls.

+
+ Back to prices +
+
+ +
+
+ @csrf + @include('platform::prices._form') + +
+ Cancel + +
+ +
+
+
diff --git a/Modules/Platform/resources/views/prices/edit.blade.php b/Modules/Platform/resources/views/prices/edit.blade.php new file mode 100644 index 00000000..1d74cd8a --- /dev/null +++ b/Modules/Platform/resources/views/prices/edit.blade.php @@ -0,0 +1,27 @@ + +
+
+
+
+

Administration

+

Edit {{ $price->name }}

+

Update the pricing card, feature list and visibility settings without changing the rest of the platform.

+
+ Back to prices +
+
+ +
+
+ @csrf + @method('PUT') + @include('platform::prices._form') + +
+ Cancel + +
+ +
+
+
diff --git a/Modules/Platform/resources/views/prices/index.blade.php b/Modules/Platform/resources/views/prices/index.blade.php new file mode 100644 index 00000000..dab1a4fc --- /dev/null +++ b/Modules/Platform/resources/views/prices/index.blade.php @@ -0,0 +1,85 @@ + +
+
+
+
+

Platform

+

Pricing plans

+

Maintain customer-facing Basic, Advanced and Pro pricing from one administration page.

+
+ +
+
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ @foreach ($prices as $price) + @php + $isBasic = $price->code === 'basic'; + $isAdvanced = $price->code === 'advanced'; + $headerClass = $isAdvanced + ? 'from-cyan-500 to-blue-600' + : ($isBasic ? 'from-slate-700 to-slate-900' : 'from-indigo-600 to-violet-700'); + @endphp +
+ @if ($price->is_featured) +
Featured
+ @endif + +
+
+
+

{{ $price->badge_label ?: 'Plan' }}

+

{{ $price->name }}

+
+ + + +
+

{{ $price->strapline }}

+
+ +
+
+ {{ $price->formatAmount($price->monthly_amount) }} + / month +
+

Annual: {{ $price->formatAmount($price->annual_amount) }}

+

{{ $price->description }}

+ +
    + @foreach ($price->featureRows() as $feature) +
  • + + {{ $feature }} +
  • + @endforeach +
+ +
+ + {{ $price->is_active ? 'Active' : 'Hidden' }} + +
+ Edit +
+ @csrf + @method('DELETE') + + +
+
+
+
+ @endforeach +
+
+
diff --git a/Modules/Platform/routes/web.php b/Modules/Platform/routes/web.php index 979c3df9..a0d6a712 100644 --- a/Modules/Platform/routes/web.php +++ b/Modules/Platform/routes/web.php @@ -5,6 +5,7 @@ use Modules\Platform\app\Http\Controllers\CompanyController; use Modules\Platform\app\Http\Controllers\LicenseKeyController; use Modules\Platform\app\Http\Controllers\PaymentController; use Modules\Platform\app\Http\Controllers\PlatformDashboardController; +use Modules\Platform\app\Http\Controllers\PlatformPriceController; use Modules\Platform\app\Http\Controllers\StripeWebhookController; Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin']) @@ -18,6 +19,7 @@ Route::middleware(['auth', 'verified', 'tenant.assigned', 'platform.admin']) Route::post('/licenses', [LicenseKeyController::class, 'store'])->name('licenses.store'); Route::get('/payments', [PaymentController::class, 'index'])->name('payments.index'); Route::post('/payments', [PaymentController::class, 'store'])->name('payments.store'); + Route::resource('/prices', PlatformPriceController::class)->except(['show']); }); Route::post('/stripe/webhook', StripeWebhookController::class)->name('stripe.webhook'); diff --git a/resources/css/app.css b/resources/css/app.css index 41c6867d..c5dd860c 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -401,10 +401,10 @@ select:focus[data-flux-control] { display: flex; align-items: center; justify-content: space-between; - gap: 2rem; - margin-bottom: 1.75rem; - border-radius: 1.35rem; - padding: 3rem 3.25rem; + gap: 1.25rem; + margin-bottom: 1.25rem; + border-radius: 1rem; + padding: 1.45rem 1.75rem; background: radial-gradient(circle at 95% 85%, rgb(8 145 178 / 0.62), transparent 36%), linear-gradient(135deg, rgb(4 8 24), rgb(10 18 42) 54%, rgb(7 44 58)); @@ -414,47 +414,47 @@ select:focus[data-flux-control] { .wms-page-hero-copy { min-width: 0; - max-width: 58rem; + max-width: 48rem; } .wms-page-hero-kicker { - margin-bottom: 1rem; + margin-bottom: 0.55rem; color: rgb(103 232 249); - font-size: 0.9rem; - font-weight: 900; - letter-spacing: 0.42em; + font-size: 0.72rem; + font-weight: 850; + letter-spacing: 0.32em; } .wms-page-hero-title { margin: 0; - font-size: clamp(2.5rem, 5vw, 4.7rem); - font-weight: 900; - line-height: 0.98; - letter-spacing: -0.06em; + font-size: clamp(1.9rem, 3vw, 2.75rem); + font-weight: 850; + line-height: 1.05; + letter-spacing: -0.04em; } .wms-page-hero-description { - max-width: 52rem; - margin-top: 1.2rem; + max-width: 44rem; + margin-top: 0.65rem; color: rgb(203 213 225); - font-size: clamp(1rem, 1.55vw, 1.45rem); + font-size: clamp(0.9rem, 1.15vw, 1.05rem); line-height: 1.45; } .wms-page-hero-actions { display: flex; align-items: stretch; - gap: 1rem; + gap: 0.75rem; flex: 0 0 auto; } .wms-page-hero-stat { display: grid; - min-width: 13.75rem; - gap: 1rem; - border-radius: 1.2rem; + min-width: 10.5rem; + gap: 0.6rem; + border-radius: 0.9rem; background: rgb(51 65 85 / 0.72); - padding: 1.45rem 1.6rem; + padding: 0.9rem 1rem; color: rgb(203 213 225); backdrop-filter: blur(10px); } @@ -464,8 +464,8 @@ select:focus[data-flux-control] { align-items: center; gap: 0.75rem; color: white; - font-size: 2rem; - font-weight: 900; + font-size: 1.35rem; + font-weight: 850; line-height: 1; } @@ -475,8 +475,8 @@ select:focus[data-flux-control] { .wms-hero-mobile-menu { display: none; - width: 3rem; - height: 3rem; + width: 2.5rem; + height: 2.5rem; place-items: center; align-self: flex-start; border-radius: 0.85rem; @@ -635,10 +635,10 @@ select:focus[data-flux-control] { .wms-page-hero { align-items: flex-start; flex-direction: column; - gap: 1.5rem; - margin-bottom: 1rem; - border-radius: 1rem; - padding: 1.35rem; + gap: 1rem; + margin-bottom: 0.85rem; + border-radius: 0.9rem; + padding: 1rem; } .wms-page-hero-actions { @@ -649,11 +649,11 @@ select:focus[data-flux-control] { .wms-page-hero-stat { min-width: min(12rem, 100%); flex: 1 1 12rem; - padding: 1rem; + padding: 0.8rem; } .wms-page-hero-stat strong { - font-size: 1.45rem; + font-size: 1.2rem; } .wms-hero-mobile-menu { diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index 6d770024..e437ed9e 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -127,6 +127,13 @@ {{ __('Validation Rules') }} @endif + + @if(auth()->user()?->isMpbaPlatformAdmin() && Route::has('platform.prices.index')) + + + {{ __('Prices') }} + + @endif @if(Route::has('configuration.index')) diff --git a/storage/framework/views/3d7c4a50df1d313410bd5444267891cf.php b/storage/framework/views/3d7c4a50df1d313410bd5444267891cf.php index 30736df4..5c8a7bf0 100755 --- a/storage/framework/views/3d7c4a50df1d313410bd5444267891cf.php +++ b/storage/framework/views/3d7c4a50df1d313410bd5444267891cf.php @@ -16,221 +16,228 @@ - - make('partials.head', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> - + + make('partials.head', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> + - -
-
+ +
+
-
+ + -