Files
autocrm/app/Filament/Central/Resources/CompanyResource/Pages/ViewCompany.php
T
Vasyka 10426d0c91 Central panel SaaS upgrade — Plans/Subscriptions/SuperAdmins/Detail page
Models & migrations:
- subscriptions table (company, plan, period, amount, status, dates, invoice)
- super_admins: role enum (owner/admin/support/sales/finance) + phone + notes
- Subscription model with STATUSES/PERIODS/PAYMENT_METHODS + invoice number
  generator + extends company.active_until on mark_paid
- Company model: subscriptions() + latestSubscription() relations
- SuperAdmin model: role helpers (isOwner, canManageBilling, canManageTenants)

Filament Central panel:
- PlanResource (CRUD, features checklist, limits per plan, abonati count badge)
- SubscriptionResource (CRUD, mark_paid action, navigation badge for overdue)
- SuperAdminResource (CRUD, reset password, toggle 2FA, can't self-delete)
- ViewCompany page with live stats (users/clients/vehicles/WO/parts/revenue/
  storage/last_login + days_until_expiry), subscriptions history table,
  config snapshot, action buttons (open/issue invoice/upload logo/suspend)
- CompanyResource: row click → view, openUrlInNewTab action, recordTitleAttribute,
  empty state, view route registered
- PlatformStats widget upgraded: 6 cards (incl. MRR realized this month, overdue
  invoices count, click-through to filtered tables)
- RevenueChart: 12-month MRR line chart
- RecentTenants: latest 8 tenants with click-through
- PendingPayments: pending+overdue invoices table
- Database notifications enabled + Cmd+K global search
- HEAD_END render hook: PWA manifest + theme color + emoji favicon
- /admin-manifest.json route

Seeder:
- Plans aligned with new FEATURE_OPTIONS (kanban/pdf/reports/ai/api/reverb/etc)
- 4 plans: Free / Basic / Pro / Enterprise (with proper limits)
- SuperAdmin gets role='owner'
- Demo subscription for psauto on Pro plan, marked paid this month
2026-05-07 22:02:44 +00:00

177 lines
7.5 KiB
PHP

<?php
namespace App\Filament\Central\Resources\CompanyResource\Pages;
use App\Filament\Central\Resources\CompanyResource;
use App\Models\Central\Company;
use App\Models\Central\Subscription;
use App\Models\Tenant\Client;
use App\Models\Tenant\Part;
use App\Models\Tenant\Payment;
use App\Models\Tenant\User as TenantUser;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
class ViewCompany extends Page
{
protected static string $resource = CompanyResource::class;
protected string $view = 'filament.central.resources.company.view';
public Company $record;
public function mount(int|string $record): void
{
$this->record = Company::with(['plan', 'subscriptions' => fn ($q) => $q->latest('period_end')->limit(10)])->findOrFail($record);
}
public function getTitle(): string
{
return $this->record->display_name ?? $this->record->name;
}
public function getStats(): array
{
// Set tenant context to query scoped tables.
app(TenantManager::class)->setCurrent($this->record);
app(\Spatie\Permission\PermissionRegistrar::class)
->setPermissionsTeamId($this->record->id);
$stats = [
'users' => TenantUser::count(),
'clients' => Client::count(),
'vehicles' => Vehicle::count(),
'work_orders' => WorkOrder::count(),
'work_orders_open' => WorkOrder::whereNotIn('status', ['done', 'cancelled'])->count(),
'parts' => Part::count(),
'parts_low_stock' => Part::where('is_active', true)
->whereColumn('stock', '<=', 'low_stock_threshold')
->where('stock', '>', 0)
->count(),
'revenue_this_month' => (float) Payment::whereYear('paid_at', date('Y'))
->whereMonth('paid_at', date('m'))->sum('amount'),
'revenue_last_month' => (float) Payment::whereYear('paid_at', now()->subMonth()->year)
->whereMonth('paid_at', now()->subMonth()->month)->sum('amount'),
'last_login' => TenantUser::whereNotNull('last_login_at')->max('last_login_at'),
'storage_mb' => $this->calculateStorageMb(),
];
app(TenantManager::class)->setCurrent(null);
return $stats;
}
private function calculateStorageMb(): float
{
try {
$bytes = (int) \DB::table('media')
->where('model_type', \App\Models\Central\Company::class)
->where('model_id', $this->record->id)
->sum('size');
return round($bytes / 1024 / 1024, 2);
} catch (\Throwable) {
return 0;
}
}
public function getDaysUntilExpiry(): ?int
{
$until = $this->record->active_until ?? $this->record->trial_ends_at;
if (! $until) return null;
$diff = now()->diffInDays($until, false);
return (int) $diff;
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('view_as')
->label('Deschide tenantul')
->icon('heroicon-m-arrow-top-right-on-square')
->color('primary')
->url(fn () => $this->record->url('/app'))
->openUrlInNewTab(),
Actions\Action::make('issue_invoice')
->label('Generează factură')
->icon('heroicon-m-document-plus')
->color('success')
->visible(fn () => (bool) $this->record->plan_id)
->schema([
Forms\Components\Select::make('period')->options(\App\Models\Central\Subscription::PERIODS)->default('monthly')->required(),
])
->action(function (array $data) {
$plan = $this->record->plan;
if (! $plan) return;
$start = today();
$end = $data['period'] === 'yearly' ? $start->copy()->addYear() : $start->copy()->addMonth();
$sub = Subscription::create([
'company_id' => $this->record->id,
'plan_id' => $plan->id,
'period' => $data['period'],
'amount' => $data['period'] === 'yearly' ? $plan->price_yearly : $plan->price_monthly,
'currency' => $plan->currency,
'status' => 'pending',
'period_start' => $start,
'period_end' => $end,
'due_at' => $start->copy()->addDays(7),
'invoice_number' => Subscription::generateInvoiceNumber(),
]);
Notification::make()
->title('Factură generată: ' . $sub->invoice_number)
->body('Suma: ' . number_format($sub->amount, 2) . ' ' . $sub->currency)
->success()->send();
$this->dispatch('$refresh');
}),
Actions\Action::make('upload_logo')
->label('Logo')
->icon('heroicon-m-photo')
->color('gray')
->schema([
Forms\Components\FileUpload::make('logo')
->image()
->imageEditor()
->disk('public')
->directory('central-uploads')
->maxSize(2048)
->required(),
])
->action(function (array $data) {
if (empty($data['logo'])) return;
$abs = \Illuminate\Support\Facades\Storage::disk('public')->path($data['logo']);
if (file_exists($abs)) {
$this->record->clearMediaCollection('logo');
$this->record->addMedia($abs)->preservingOriginal()->toMediaCollection('logo');
@unlink($abs);
Notification::make()->title('Logo actualizat')->success()->send();
}
}),
Actions\Action::make('edit')
->label('Editează')
->icon('heroicon-m-pencil')
->url(fn () => CompanyResource::getUrl('edit', ['record' => $this->record])),
Actions\Action::make('suspend')
->label('Suspendă')->icon('heroicon-m-no-symbol')->color('danger')
->visible(fn () => in_array($this->record->status, ['active', 'trial']))
->requiresConfirmation()
->action(function () {
app(\App\Services\CompanyProvisioner::class)->suspend($this->record);
Notification::make()->title('Tenant suspendat.')->success()->send();
$this->dispatch('$refresh');
}),
Actions\Action::make('activate')
->label('Reactivează')->icon('heroicon-m-check-circle')->color('success')
->visible(fn () => in_array($this->record->status, ['suspended', 'expired']))
->requiresConfirmation()
->action(function () {
app(\App\Services\CompanyProvisioner::class)->reactivate($this->record);
Notification::make()->title('Tenant activat.')->success()->send();
$this->dispatch('$refresh');
}),
];
}
}