10426d0c91
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
177 lines
7.5 KiB
PHP
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');
|
|
}),
|
|
];
|
|
}
|
|
}
|