From 10426d0c91b9e1d39cf61b869e6c060f5e91f363 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 7 May 2026 22:02:44 +0000 Subject: [PATCH] =?UTF-8?q?Central=20panel=20SaaS=20upgrade=20=E2=80=94=20?= =?UTF-8?q?Plans/Subscriptions/SuperAdmins/Detail=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Central/Resources/CompanyResource.php | 22 ++ .../CompanyResource/Pages/ViewCompany.php | 176 +++++++++++++ .../Central/Resources/PlanResource.php | 125 +++++++++ .../PlanResource/Pages/CreatePlan.php | 11 + .../Resources/PlanResource/Pages/EditPlan.php | 17 ++ .../PlanResource/Pages/ListPlans.php | 17 ++ .../Resources/SubscriptionResource.php | 174 +++++++++++++ .../Pages/CreateSubscription.php | 20 ++ .../Pages/EditSubscription.php | 17 ++ .../Pages/ListSubscriptions.php | 17 ++ .../Central/Resources/SuperAdminResource.php | 150 +++++++++++ .../Pages/CreateSuperAdmin.php | 11 + .../Pages/EditSuperAdmin.php | 17 ++ .../Pages/ListSuperAdmins.php | 17 ++ .../Central/Widgets/PendingPayments.php | 48 ++++ .../Central/Widgets/PlatformStats.php | 43 +++- .../Central/Widgets/RecentTenants.php | 41 +++ app/Filament/Central/Widgets/RevenueChart.php | 48 ++++ app/Models/Central/Company.php | 10 + app/Models/Central/Subscription.php | 64 +++++ app/Models/Central/SuperAdmin.php | 15 +- .../Filament/CentralPanelProvider.php | 23 ++ ...5_08_000030_create_subscriptions_table.php | 44 ++++ ..._05_08_000040_add_role_to_super_admins.php | 24 ++ database/seeders/DatabaseSeeder.php | 49 +++- .../central/resources/company/view.blade.php | 239 ++++++++++++++++++ routes/web.php | 19 ++ 27 files changed, 1442 insertions(+), 16 deletions(-) create mode 100644 app/Filament/Central/Resources/CompanyResource/Pages/ViewCompany.php create mode 100644 app/Filament/Central/Resources/PlanResource.php create mode 100644 app/Filament/Central/Resources/PlanResource/Pages/CreatePlan.php create mode 100644 app/Filament/Central/Resources/PlanResource/Pages/EditPlan.php create mode 100644 app/Filament/Central/Resources/PlanResource/Pages/ListPlans.php create mode 100644 app/Filament/Central/Resources/SubscriptionResource.php create mode 100644 app/Filament/Central/Resources/SubscriptionResource/Pages/CreateSubscription.php create mode 100644 app/Filament/Central/Resources/SubscriptionResource/Pages/EditSubscription.php create mode 100644 app/Filament/Central/Resources/SubscriptionResource/Pages/ListSubscriptions.php create mode 100644 app/Filament/Central/Resources/SuperAdminResource.php create mode 100644 app/Filament/Central/Resources/SuperAdminResource/Pages/CreateSuperAdmin.php create mode 100644 app/Filament/Central/Resources/SuperAdminResource/Pages/EditSuperAdmin.php create mode 100644 app/Filament/Central/Resources/SuperAdminResource/Pages/ListSuperAdmins.php create mode 100644 app/Filament/Central/Widgets/PendingPayments.php create mode 100644 app/Filament/Central/Widgets/RecentTenants.php create mode 100644 app/Filament/Central/Widgets/RevenueChart.php create mode 100644 app/Models/Central/Subscription.php create mode 100644 database/migrations/2026_05_08_000030_create_subscriptions_table.php create mode 100644 database/migrations/2026_05_08_000040_add_role_to_super_admins.php create mode 100644 resources/views/filament/central/resources/company/view.blade.php diff --git a/app/Filament/Central/Resources/CompanyResource.php b/app/Filament/Central/Resources/CompanyResource.php index ac31c60..13e5fa0 100644 --- a/app/Filament/Central/Resources/CompanyResource.php +++ b/app/Filament/Central/Resources/CompanyResource.php @@ -25,6 +25,15 @@ class CompanyResource extends Resource protected static ?string $pluralModelLabel = 'companii'; + protected static ?int $navigationSort = 10; + + protected static ?string $recordTitleAttribute = 'name'; + + public static function getGloballySearchableAttributes(): array + { + return ['slug', 'name', 'display_name', 'city', 'phone', 'email']; + } + public static function form(Schema $schema): Schema { return $schema->components([ @@ -123,7 +132,16 @@ class CompanyResource extends Resource 'suspended' => 'Suspendat', 'archived' => 'Arhivat', ]), ]) + ->recordUrl(fn (Company $r) => CompanyResource::getUrl('view', ['record' => $r])) ->actions([ + Actions\ViewAction::make() + ->url(fn (Company $r) => CompanyResource::getUrl('view', ['record' => $r])), + Actions\Action::make('open_tenant') + ->label('Deschide') + ->icon('heroicon-m-arrow-top-right-on-square') + ->color('primary') + ->url(fn (Company $r) => $r->url('/app')) + ->openUrlInNewTab(), Actions\Action::make('suspend') ->label('Suspendă') ->icon('heroicon-m-no-symbol') @@ -141,6 +159,9 @@ class CompanyResource extends Resource Actions\EditAction::make(), Actions\DeleteAction::make(), ]) + ->emptyStateHeading('Niciun tenant încă') + ->emptyStateDescription('Adaugă primul client SaaS folosind butonul „Adaugă companie" — se va crea un tenant nou cu admin pe `.service.mir.md/app`.') + ->emptyStateIcon('heroicon-o-building-office-2') ->defaultSort('created_at', 'desc'); } @@ -149,6 +170,7 @@ class CompanyResource extends Resource return [ 'index' => Pages\ListCompanies::route('/'), 'create' => Pages\CreateCompany::route('/create'), + 'view' => Pages\ViewCompany::route('/{record}'), 'edit' => Pages\EditCompany::route('/{record}/edit'), ]; } diff --git a/app/Filament/Central/Resources/CompanyResource/Pages/ViewCompany.php b/app/Filament/Central/Resources/CompanyResource/Pages/ViewCompany.php new file mode 100644 index 0000000..3a7349a --- /dev/null +++ b/app/Filament/Central/Resources/CompanyResource/Pages/ViewCompany.php @@ -0,0 +1,176 @@ +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'); + }), + ]; + } +} diff --git a/app/Filament/Central/Resources/PlanResource.php b/app/Filament/Central/Resources/PlanResource.php new file mode 100644 index 0000000..01f04db --- /dev/null +++ b/app/Filament/Central/Resources/PlanResource.php @@ -0,0 +1,125 @@ + 'Kanban WO', + 'reports' => 'Rapoarte avansate', + 'ai' => 'Asistent AI', + 'pdf' => 'Generare PDF', + 'reverb' => 'WebSocket real-time', + 'api' => 'REST API + tokens', + 'multi_user' => 'Multi-user (>1 cont)', + 'white_label' => 'White-label complet', + 'priority_support' => 'Suport prioritar', + 'custom_domain' => 'Domeniu propriu', + ]; + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Schemas\Components\Section::make('Identificare') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('slug') + ->required()->alphaDash()->unique(ignoreRecord: true) + ->dehydrateStateUsing(fn ($s) => strtolower((string) $s)) + ->extraInputAttributes(['style' => 'text-transform:lowercase']), + Forms\Components\TextInput::make('name')->required()->maxLength(60), + Forms\Components\Toggle::make('is_active')->label('Activ')->default(true), + Forms\Components\Toggle::make('is_public')->label('Public (afișat la signup)')->default(true), + ]), + Schemas\Components\Section::make('Preț') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('price_monthly')->label('Preț lunar')->numeric()->required()->suffix(fn (Forms\Get $get) => $get('currency') ?? 'MDL'), + Forms\Components\TextInput::make('price_yearly')->label('Preț anual')->numeric()->helperText('De obicei 10× preț lunar (2 luni gratis).')->suffix(fn (Forms\Get $get) => $get('currency') ?? 'MDL'), + Forms\Components\Select::make('currency')->options(['MDL' => 'MDL', 'EUR' => 'EUR', 'USD' => 'USD'])->default('MDL'), + ]), + Schemas\Components\Section::make('Funcționalități incluse') + ->columns(2) + ->schema([ + Forms\Components\CheckboxList::make('features') + ->options(self::FEATURE_OPTIONS) + ->columns(2) + ->columnSpanFull(), + ]), + Schemas\Components\Section::make('Limite') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('limits.max_users')->label('Max useri')->numeric()->placeholder('∞'), + Forms\Components\TextInput::make('limits.max_clients')->label('Max clienți')->numeric()->placeholder('∞'), + Forms\Components\TextInput::make('limits.max_vehicles')->label('Max mașini')->numeric()->placeholder('∞'), + Forms\Components\TextInput::make('limits.max_work_orders_month')->label('Max fișe/lună')->numeric()->placeholder('∞'), + Forms\Components\TextInput::make('limits.storage_mb')->label('Storage (MB)')->numeric()->placeholder('∞'), + Forms\Components\TextInput::make('limits.ai_messages_month')->label('Mesaje AI/lună')->numeric()->placeholder('∞'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('price_monthly') + ->money(fn ($r) => $r->currency) + ->label('Lunar') + ->sortable(), + Tables\Columns\TextColumn::make('price_yearly') + ->money(fn ($r) => $r->currency) + ->label('Anual'), + Tables\Columns\TextColumn::make('companies_count') + ->counts('companies') + ->label('Abonați') + ->badge() + ->color('primary'), + Tables\Columns\TextColumn::make('features') + ->label('Funcționalități') + ->formatStateUsing(fn ($s) => is_array($s) ? count($s) . ' incluse' : '—') + ->badge(), + Tables\Columns\IconColumn::make('is_active')->boolean()->label('Activ'), + Tables\Columns\IconColumn::make('is_public')->boolean()->label('Public'), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Niciun plan definit') + ->emptyStateDescription('Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.') + ->defaultSort('price_monthly'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPlans::route('/'), + 'create' => Pages\CreatePlan::route('/create'), + 'edit' => Pages\EditPlan::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Central/Resources/PlanResource/Pages/CreatePlan.php b/app/Filament/Central/Resources/PlanResource/Pages/CreatePlan.php new file mode 100644 index 0000000..bd81805 --- /dev/null +++ b/app/Filament/Central/Resources/PlanResource/Pages/CreatePlan.php @@ -0,0 +1,11 @@ +count(); + return $overdue > 0 ? (string) $overdue : null; + } + + public static function getNavigationBadgeColor(): ?string { return 'danger'; } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Schemas\Components\Section::make('Companie & plan') + ->columns(2) + ->schema([ + Forms\Components\Select::make('company_id') + ->label('Companie') + ->options(fn () => Company::orderBy('name')->pluck('name', 'id')) + ->required()->searchable()->live(), + Forms\Components\Select::make('plan_id') + ->label('Plan') + ->options(fn () => Plan::pluck('name', 'id')) + ->required()->searchable()->live() + ->afterStateUpdated(function ($state, Forms\Set $set, Forms\Get $get) { + if (! $state) return; + $plan = Plan::find($state); + if (! $plan) return; + $period = $get('period') ?? 'monthly'; + $set('amount', $period === 'yearly' ? $plan->price_yearly : $plan->price_monthly); + $set('currency', $plan->currency); + }), + Forms\Components\Select::make('period') + ->options(Subscription::PERIODS)->default('monthly')->required()->live() + ->afterStateUpdated(function ($state, Forms\Set $set, Forms\Get $get) { + $plan = Plan::find($get('plan_id')); + if (! $plan) return; + $set('amount', $state === 'yearly' ? $plan->price_yearly : $plan->price_monthly); + // Auto-fill period_end based on period + $start = $get('period_start') ?? now()->toDateString(); + $end = $state === 'yearly' + ? \Carbon\Carbon::parse($start)->addYear()->toDateString() + : \Carbon\Carbon::parse($start)->addMonth()->toDateString(); + $set('period_end', $end); + }), + Forms\Components\Select::make('status') + ->options(Subscription::STATUSES)->default('pending')->required(), + ]), + Schemas\Components\Section::make('Sumă') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('amount')->numeric()->required()->suffix(fn (Forms\Get $get) => $get('currency') ?? 'MDL'), + Forms\Components\Select::make('currency')->options(['MDL' => 'MDL', 'EUR' => 'EUR', 'USD' => 'USD'])->default('MDL'), + Forms\Components\Select::make('payment_method') + ->options(Subscription::PAYMENT_METHODS), + ]), + Schemas\Components\Section::make('Perioadă') + ->columns(3) + ->schema([ + Forms\Components\DatePicker::make('period_start')->required()->default(today()), + Forms\Components\DatePicker::make('period_end')->required(), + Forms\Components\DateTimePicker::make('due_at')->label('Scadent la'), + Forms\Components\DateTimePicker::make('paid_at')->label('Plătit la'), + ]), + Schemas\Components\Section::make('Detalii') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('invoice_number')->label('Nr. factură')->placeholder('auto-generat')->maxLength(30), + Forms\Components\TextInput::make('reference')->label('Referință (Stripe id, transfer)')->maxLength(100), + Forms\Components\Textarea::make('notes')->columnSpanFull()->rows(2), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('invoice_number') + ->label('Factură') + ->placeholder(fn ($r) => '—') + ->copyable() + ->searchable(), + Tables\Columns\TextColumn::make('company.name') + ->label('Companie') + ->searchable() + ->url(fn ($r) => route('filament.central.resources.companies.edit', ['record' => $r->company_id])), + Tables\Columns\TextColumn::make('plan.name')->label('Plan')->placeholder('—'), + Tables\Columns\TextColumn::make('period') + ->formatStateUsing(fn ($s) => Subscription::PERIODS[$s] ?? $s) + ->badge(), + Tables\Columns\TextColumn::make('amount') + ->money(fn ($r) => $r->currency) + ->sortable() + ->weight('bold'), + Tables\Columns\TextColumn::make('status') + ->badge() + ->formatStateUsing(fn ($s) => Subscription::STATUSES[$s] ?? $s) + ->color(fn ($s) => match ($s) { + 'paid' => 'success', + 'overdue' => 'danger', + 'pending' => 'warning', + 'cancelled', 'refunded' => 'gray', + default => 'primary', + }), + Tables\Columns\TextColumn::make('period_end')->label('Până la')->date(), + Tables\Columns\TextColumn::make('paid_at')->label('Plătit')->date()->placeholder('—'), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status')->options(Subscription::STATUSES), + Tables\Filters\SelectFilter::make('period')->options(Subscription::PERIODS), + ]) + ->actions([ + Actions\Action::make('mark_paid') + ->label('Marchează plătit') + ->icon('heroicon-m-check-circle') + ->color('success') + ->visible(fn ($r) => $r->status !== 'paid') + ->requiresConfirmation() + ->action(function (Subscription $r) { + $r->update(['status' => 'paid', 'paid_at' => now()]); + // Auto-extend company subscription + $r->company->update([ + 'status' => 'active', + 'active_until' => $r->period_end, + ]); + Notification::make()->title('Plată confirmată. Abonament extins până la ' . $r->period_end->format('d.m.Y'))->success()->send(); + }), + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Nicio factură generată') + ->emptyStateDescription('Crează manual prima factură sau folosește butonul „Generează factură" din pagina Companiei.') + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListSubscriptions::route('/'), + 'create' => Pages\CreateSubscription::route('/create'), + 'edit' => Pages\EditSubscription::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Central/Resources/SubscriptionResource/Pages/CreateSubscription.php b/app/Filament/Central/Resources/SubscriptionResource/Pages/CreateSubscription.php new file mode 100644 index 0000000..f24af61 --- /dev/null +++ b/app/Filament/Central/Resources/SubscriptionResource/Pages/CreateSubscription.php @@ -0,0 +1,20 @@ +components([ + Schemas\Components\Section::make('Identificare') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('name')->required()->maxLength(120), + Forms\Components\TextInput::make('email')->email()->required()->unique(ignoreRecord: true)->maxLength(120), + Forms\Components\TextInput::make('phone')->tel()->maxLength(40), + Forms\Components\Toggle::make('is_active')->label('Activ')->default(true), + ]), + Schemas\Components\Section::make('Permisiuni') + ->columns(1) + ->schema([ + Forms\Components\Select::make('role') + ->options(SuperAdmin::ROLES) + ->default('support') + ->required() + ->helperText('Owner = drepturi totale. Admin = aproape la fel. Sales = doar tenanți + planuri. Finance = facturi. Support = read-only.'), + ]), + Schemas\Components\Section::make('Parolă') + ->columns(1) + ->schema([ + Forms\Components\TextInput::make('password') + ->password() + ->revealable() + ->dehydrated(fn ($state) => filled($state)) + ->dehydrateStateUsing(fn ($state) => Hash::make($state)) + ->required(fn (string $operation) => $operation === 'create') + ->minLength(8) + ->helperText(fn (string $operation) => $operation === 'edit' ? 'Lasă gol ca să o păstrezi.' : 'Min 8 caractere.'), + ]), + Forms\Components\Textarea::make('notes')->label('Note interne')->rows(2)->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable()->sortable()->weight('bold'), + Tables\Columns\TextColumn::make('email')->searchable()->copyable(), + Tables\Columns\TextColumn::make('role') + ->badge() + ->formatStateUsing(fn ($s) => array_keys(SuperAdmin::ROLES, SuperAdmin::ROLES[$s] ?? $s)[0] ?? $s) + ->color(fn ($s) => match ($s) { + 'owner' => 'danger', + 'admin' => 'warning', + 'finance' => 'success', + 'sales' => 'info', + default => 'gray', + }), + Tables\Columns\IconColumn::make('is_active')->boolean()->label('Activ'), + Tables\Columns\IconColumn::make('app_authentication_secret') + ->label('2FA') + ->boolean() + ->getStateUsing(fn ($r) => $r->app_authentication_secret !== null) + ->trueIcon('heroicon-o-lock-closed') + ->falseIcon('heroicon-o-lock-open') + ->trueColor('success') + ->falseColor('warning'), + Tables\Columns\TextColumn::make('last_login_at')->label('Ultim login')->dateTime()->placeholder('—'), + Tables\Columns\TextColumn::make('created_at')->date(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('role')->options(SuperAdmin::ROLES), + Tables\Filters\TernaryFilter::make('is_active')->label('Activ'), + ]) + ->actions([ + Actions\Action::make('reset_password') + ->label('Reset parolă') + ->icon('heroicon-m-key') + ->color('warning') + ->schema([ + Forms\Components\TextInput::make('new_password') + ->password()->required()->minLength(8)->revealable(), + ]) + ->action(function (SuperAdmin $r, array $data) { + $r->update(['password' => Hash::make($data['new_password'])]); + Notification::make()->title('Parolă resetată.')->success()->send(); + }), + Actions\Action::make('toggle_2fa') + ->label(fn ($r) => $r->app_authentication_secret ? 'Dezactivează 2FA' : 'Forțează re-setare 2FA') + ->icon('heroicon-m-lock-open') + ->color('danger') + ->visible(fn ($r) => $r->app_authentication_secret !== null) + ->requiresConfirmation() + ->action(function (SuperAdmin $r) { + $r->forceFill([ + 'app_authentication_secret' => null, + 'app_authentication_recovery_codes' => null, + 'email_authentication_at' => null, + ])->saveQuietly(); + Notification::make()->title('2FA dezactivat.')->success()->send(); + }), + Actions\EditAction::make(), + Actions\DeleteAction::make() + ->before(function (SuperAdmin $r) { + if (auth('central')->id() === $r->id) { + Notification::make()->title('Nu te poți șterge pe tine!')->danger()->send(); + return false; + } + }), + ]) + ->emptyStateHeading('Doar tu ești aici') + ->emptyStateDescription('Adaugă echipa ta — colegi de la suport, sales, finance — fiecare cu rolul lui.') + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListSuperAdmins::route('/'), + 'create' => Pages\CreateSuperAdmin::route('/create'), + 'edit' => Pages\EditSuperAdmin::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Central/Resources/SuperAdminResource/Pages/CreateSuperAdmin.php b/app/Filament/Central/Resources/SuperAdminResource/Pages/CreateSuperAdmin.php new file mode 100644 index 0000000..0515e18 --- /dev/null +++ b/app/Filament/Central/Resources/SuperAdminResource/Pages/CreateSuperAdmin.php @@ -0,0 +1,11 @@ +heading('Facturi pending / overdue') + ->query( + Subscription::query() + ->whereIn('status', ['pending', 'overdue']) + ->with(['company', 'plan']) + ->latest('due_at') + ->limit(10) + ) + ->columns([ + Tables\Columns\TextColumn::make('invoice_number') + ->label('Factură') + ->copyable() + ->url(fn ($r) => SubscriptionResource::getUrl('edit', ['record' => $r])), + Tables\Columns\TextColumn::make('company.name')->label('Companie'), + Tables\Columns\TextColumn::make('plan.name')->placeholder('—'), + Tables\Columns\TextColumn::make('amount')->money(fn ($r) => $r->currency)->weight('bold'), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn ($s) => $s === 'overdue' ? 'danger' : 'warning') + ->formatStateUsing(fn ($s) => Subscription::STATUSES[$s] ?? $s), + Tables\Columns\TextColumn::make('due_at') + ->label('Scadent') + ->dateTime() + ->color(fn ($r) => $r->due_at && $r->due_at->isPast() ? 'danger' : null), + ]) + ->emptyStateHeading('🎉 Toate facturile sunt plătite') + ->paginated(false); + } +} diff --git a/app/Filament/Central/Widgets/PlatformStats.php b/app/Filament/Central/Widgets/PlatformStats.php index 91a87b5..8468a83 100644 --- a/app/Filament/Central/Widgets/PlatformStats.php +++ b/app/Filament/Central/Widgets/PlatformStats.php @@ -3,6 +3,7 @@ namespace App\Filament\Central\Widgets; use App\Models\Central\Company; +use App\Models\Central\Subscription; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; @@ -20,20 +21,44 @@ class PlatformStats extends BaseWidget ->whereDate('active_until', '<=', now()->addDays(7)) ->count(); + $mrr = (float) Subscription::where('status', 'paid') + ->whereYear('paid_at', date('Y')) + ->whereMonth('paid_at', date('m')) + ->sum('amount'); + + $overdue = Subscription::where('status', 'overdue')->count() + + Subscription::where('status', 'pending') + ->whereNotNull('due_at') + ->where('due_at', '<', now()) + ->count(); + return [ - Stat::make('Companii total', $total) - ->icon('heroicon-o-building-office-2') - ->color('primary'), + Stat::make('Tenants total', $total) + ->description('Toate companiile') + ->descriptionIcon('heroicon-m-building-office-2') + ->color('primary') + ->url(\App\Filament\Central\Resources\CompanyResource::getUrl('index')), Stat::make('Active', $active) - ->icon('heroicon-o-check-circle') + ->description("✓ Pe abonament plătit") + ->descriptionIcon('heroicon-m-check-circle') ->color('success'), - Stat::make('Trial', $trial) - ->icon('heroicon-o-clock') + Stat::make('În trial', $trial) + ->description($trial > 0 ? 'Posibili clienți noi' : '—') + ->descriptionIcon('heroicon-m-clock') ->color('warning'), - Stat::make('Expiră în 7 zile', $expiring) - ->description($expiring > 0 ? 'Atenție!' : 'Toate ok') - ->icon('heroicon-o-exclamation-triangle') + Stat::make('Expiră < 7 zile', $expiring) + ->description($expiring > 0 ? '⚠ Acțiune necesară' : 'Toate ok') + ->descriptionIcon('heroicon-m-exclamation-triangle') ->color($expiring > 0 ? 'danger' : 'success'), + Stat::make('Venit luna curentă', number_format($mrr, 0, ',', ' ') . ' MDL') + ->description('MRR realizat') + ->descriptionIcon('heroicon-m-banknotes') + ->color('success'), + Stat::make('Facturi neplătite', $overdue) + ->description($overdue > 0 ? 'Verifică și amintește' : 'Toate plătite') + ->descriptionIcon('heroicon-m-exclamation-circle') + ->color($overdue > 0 ? 'danger' : 'gray') + ->url(\App\Filament\Central\Resources\SubscriptionResource::getUrl('index') . '?activeTab=overdue'), ]; } } diff --git a/app/Filament/Central/Widgets/RecentTenants.php b/app/Filament/Central/Widgets/RecentTenants.php new file mode 100644 index 0000000..3ffb0c7 --- /dev/null +++ b/app/Filament/Central/Widgets/RecentTenants.php @@ -0,0 +1,41 @@ +heading('Tenants recenți') + ->query(Company::query()->latest()->limit(8)) + ->columns([ + Tables\Columns\TextColumn::make('slug') + ->copyable() + ->url(fn (Company $r) => CompanyResource::getUrl('view', ['record' => $r])), + Tables\Columns\TextColumn::make('name')->weight('bold'), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn ($s) => match ($s) { + 'active' => 'success', + 'trial' => 'warning', + 'suspended', 'expired' => 'danger', + default => 'gray', + }), + Tables\Columns\TextColumn::make('plan.name')->placeholder('—'), + Tables\Columns\TextColumn::make('users_count')->counts('users')->label('Useri'), + Tables\Columns\TextColumn::make('created_at')->label('Creat')->since(), + ]) + ->paginated(false); + } +} diff --git a/app/Filament/Central/Widgets/RevenueChart.php b/app/Filament/Central/Widgets/RevenueChart.php new file mode 100644 index 0000000..76ced8d --- /dev/null +++ b/app/Filament/Central/Widgets/RevenueChart.php @@ -0,0 +1,48 @@ +map(fn ($n) => now()->subMonths($n)); + + $values = $months->map(function ($m) { + return (float) Subscription::where('status', 'paid') + ->whereYear('paid_at', $m->year) + ->whereMonth('paid_at', $m->month) + ->sum('amount'); + }); + + return [ + 'datasets' => [ + [ + 'label' => 'MRR (MDL)', + 'data' => $values->toArray(), + 'backgroundColor' => 'rgba(34, 197, 94, 0.15)', + 'borderColor' => '#22c55e', + 'tension' => 0.35, + 'fill' => true, + ], + ], + 'labels' => $months->map(fn ($m) => $m->format('M Y'))->toArray(), + ]; + } + + protected function getType(): string + { + return 'line'; + } +} diff --git a/app/Models/Central/Company.php b/app/Models/Central/Company.php index 35e3aac..f036c4c 100644 --- a/app/Models/Central/Company.php +++ b/app/Models/Central/Company.php @@ -56,6 +56,16 @@ class Company extends BaseTenant implements HasMedia return $this->hasMany(User::class); } + public function subscriptions() + { + return $this->hasMany(Subscription::class); + } + + public function latestSubscription() + { + return $this->hasOne(Subscription::class)->latestOfMany('period_end'); + } + public function isActive(): bool { return in_array($this->status, ['active', 'trial'], true); diff --git a/app/Models/Central/Subscription.php b/app/Models/Central/Subscription.php new file mode 100644 index 0000000..48bbb74 --- /dev/null +++ b/app/Models/Central/Subscription.php @@ -0,0 +1,64 @@ + 'În așteptare', + 'paid' => 'Plătit', + 'overdue' => 'Depășit termen', + 'cancelled' => 'Anulat', + 'refunded' => 'Refundat', + ]; + + public const PERIODS = [ + 'monthly' => 'Lunar', + 'yearly' => 'Anual', + ]; + + public const PAYMENT_METHODS = [ + 'card' => 'Card', + 'bank_transfer' => 'Transfer bancar', + 'cash' => 'Numerar', + 'other' => 'Altă metodă', + ]; + + protected $fillable = [ + 'company_id', 'plan_id', + 'period', 'amount', 'currency', 'status', + 'period_start', 'period_end', 'paid_at', 'due_at', + 'invoice_number', 'payment_method', 'reference', 'notes', + ]; + + protected $casts = [ + 'amount' => 'decimal:2', + 'period_start' => 'date', + 'period_end' => 'date', + 'paid_at' => 'datetime', + 'due_at' => 'datetime', + ]; + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + public static function generateInvoiceNumber(): string + { + return sprintf('INV-%s-%04d', + date('Ym'), + (static::whereYear('created_at', date('Y')) + ->whereMonth('created_at', date('m')) + ->count()) + 1 + ); + } +} diff --git a/app/Models/Central/SuperAdmin.php b/app/Models/Central/SuperAdmin.php index ae5c54d..d12c0ec 100644 --- a/app/Models/Central/SuperAdmin.php +++ b/app/Models/Central/SuperAdmin.php @@ -19,8 +19,16 @@ class SuperAdmin extends Authenticatable implements FilamentUser, HasAppAuthenti protected $table = 'super_admins'; + public const ROLES = [ + 'owner' => 'Proprietar (toate drepturile)', + 'admin' => 'Administrator', + 'support' => 'Suport (read-only + acces tenanți)', + 'sales' => 'Vânzări (gestiune Companii + Plans)', + 'finance' => 'Financiar (Subscriptions + facturi)', + ]; + protected $fillable = [ - 'name', 'email', 'password', 'is_active', 'last_login_at', + 'name', 'email', 'phone', 'password', 'is_active', 'role', 'notes', 'last_login_at', 'email_authentication_at', 'app_authentication_secret', 'app_authentication_recovery_codes', ]; @@ -47,6 +55,11 @@ class SuperAdmin extends Authenticatable implements FilamentUser, HasAppAuthenti return $panel->getId() === 'central' && $this->is_active; } + public function isOwner(): bool { return $this->role === 'owner'; } + public function isAdmin(): bool { return in_array($this->role, ['owner', 'admin'], true); } + public function canManageBilling(): bool { return in_array($this->role, ['owner', 'admin', 'finance'], true); } + public function canManageTenants(): bool { return in_array($this->role, ['owner', 'admin', 'sales'], true); } + public function hasEmailAuthentication(): bool { return $this->email_authentication_at !== null; diff --git a/app/Providers/Filament/CentralPanelProvider.php b/app/Providers/Filament/CentralPanelProvider.php index 1011de7..39cf7c5 100644 --- a/app/Providers/Filament/CentralPanelProvider.php +++ b/app/Providers/Filament/CentralPanelProvider.php @@ -10,11 +10,13 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; +use Filament\View\PanelsRenderHook; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; +use Illuminate\Support\Facades\Blade; use Illuminate\View\Middleware\ShareErrorsFromSession; /** @@ -50,7 +52,28 @@ class CentralPanelProvider extends PanelProvider ->discoverWidgets(in: app_path('Filament/Central/Widgets'), for: 'App\\Filament\\Central\\Widgets') ->widgets([ \App\Filament\Central\Widgets\PlatformStats::class, + \App\Filament\Central\Widgets\RevenueChart::class, + \App\Filament\Central\Widgets\PendingPayments::class, + \App\Filament\Central\Widgets\RecentTenants::class, ]) + ->databaseNotifications() + ->databaseNotificationsPolling('60s') + ->globalSearchKeyBindings(['command+k', 'ctrl+k']) + ->navigationGroups([ + 'Tenants', + 'Acces', + ]) + ->renderHook( + PanelsRenderHook::HEAD_END, + fn (): string => Blade::render(<<<'BLADE' + + + + + + + BLADE) + ) ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, diff --git a/database/migrations/2026_05_08_000030_create_subscriptions_table.php b/database/migrations/2026_05_08_000030_create_subscriptions_table.php new file mode 100644 index 0000000..a82fb41 --- /dev/null +++ b/database/migrations/2026_05_08_000030_create_subscriptions_table.php @@ -0,0 +1,44 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('plan_id')->nullable()->constrained()->nullOnDelete(); + + $t->enum('period', ['monthly', 'yearly'])->default('monthly'); + $t->decimal('amount', 10, 2); + $t->string('currency', 3)->default('MDL'); + + $t->enum('status', ['pending', 'paid', 'overdue', 'cancelled', 'refunded']) + ->default('pending'); + + $t->date('period_start'); + $t->date('period_end'); + $t->timestamp('paid_at')->nullable(); + $t->timestamp('due_at')->nullable(); + + $t->string('invoice_number', 30)->nullable(); + $t->string('payment_method', 30)->nullable(); // card, bank_transfer, cash + $t->string('reference', 100)->nullable(); // Stripe id, transfer ref + $t->text('notes')->nullable(); + + $t->timestamps(); + + $t->index(['company_id', 'status']); + $t->index('due_at'); + $t->index('paid_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/database/migrations/2026_05_08_000040_add_role_to_super_admins.php b/database/migrations/2026_05_08_000040_add_role_to_super_admins.php new file mode 100644 index 0000000..93ef748 --- /dev/null +++ b/database/migrations/2026_05_08_000040_add_role_to_super_admins.php @@ -0,0 +1,24 @@ +enum('role', ['owner', 'admin', 'support', 'sales', 'finance']) + ->default('support')->after('is_active'); + $t->string('phone', 40)->nullable()->after('email'); + $t->text('notes')->nullable()->after('phone'); + }); + } + + public function down(): void + { + Schema::table('super_admins', function (Blueprint $t) { + $t->dropColumn(['role', 'phone', 'notes']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9673a2e..de3bef7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -39,25 +39,41 @@ class DatabaseSeeder extends Seeder $free = Plan::firstOrCreate(['slug' => 'free'], [ 'name' => 'Free', 'price_monthly' => 0, + 'price_yearly' => 0, 'currency' => 'MDL', - 'features' => ['core'], - 'limits' => ['max_users' => 2, 'max_vehicles' => 50], + 'features' => ['kanban'], + 'limits' => ['max_users' => 2, 'max_vehicles' => 50, 'max_clients' => 100, 'storage_mb' => 100], + 'is_active' => true, 'is_public' => true, ]); Plan::firstOrCreate(['slug' => 'basic'], [ 'name' => 'Basic', 'price_monthly' => 299, + 'price_yearly' => 2990, 'currency' => 'MDL', - 'features' => ['core', 'workorders', 'kanban'], - 'limits' => ['max_users' => 5, 'max_vehicles' => 500], + 'features' => ['kanban', 'pdf', 'reports'], + 'limits' => ['max_users' => 5, 'max_vehicles' => 500, 'max_clients' => 1000, 'storage_mb' => 1000], + 'is_active' => true, 'is_public' => true, ]); Plan::firstOrCreate(['slug' => 'pro'], [ 'name' => 'Pro', 'price_monthly' => 599, + 'price_yearly' => 5990, 'currency' => 'MDL', - 'features' => ['core', 'workorders', 'kanban', 'reports', 'ai', 'api'], - 'limits' => ['max_users' => -1, 'max_vehicles' => -1], + 'features' => ['kanban', 'pdf', 'reports', 'ai', 'api', 'reverb', 'multi_user', 'white_label'], + 'limits' => ['max_users' => 20, 'max_vehicles' => 5000, 'max_clients' => 10000, 'storage_mb' => 10000, 'ai_messages_month' => 1000], + 'is_active' => true, 'is_public' => true, + ]); + + Plan::firstOrCreate(['slug' => 'enterprise'], [ + 'name' => 'Enterprise', + 'price_monthly' => 1499, + 'price_yearly' => 14990, + 'currency' => 'MDL', + 'features' => ['kanban', 'pdf', 'reports', 'ai', 'api', 'reverb', 'multi_user', 'white_label', 'priority_support', 'custom_domain'], + 'limits' => [], + 'is_active' => true, 'is_public' => true, ]); // ─── Super-admin (operator platformă) ───────────────────── @@ -65,8 +81,29 @@ class DatabaseSeeder extends Seeder 'name' => 'Vasyka', 'password' => Hash::make('admin123'), 'is_active' => true, + 'role' => 'owner', ]); + // ─── Subscription demo (PSauto pe Pro plan, plătit) ────── + $proPlan = Plan::where('slug', 'pro')->first(); + $psautoCompany = Company::where('slug', 'psauto')->first(); + if ($proPlan && $psautoCompany) { + \App\Models\Central\Subscription::firstOrCreate( + ['company_id' => $psautoCompany->id, 'period_start' => today()->startOfMonth()], + [ + 'plan_id' => $proPlan->id, + 'period' => 'monthly', + 'amount' => $proPlan->price_monthly, + 'currency' => 'MDL', + 'status' => 'paid', + 'period_end' => today()->endOfMonth(), + 'paid_at' => today()->startOfMonth()->addDay(), + 'invoice_number' => 'INV-' . date('Ym') . '-0001', + 'payment_method' => 'bank_transfer', + ] + ); + } + // ─── PSauto demo company ────────────────────────────────── $psauto = Company::firstOrCreate(['slug' => 'psauto'], [ 'name' => 'PSauto SRL', diff --git a/resources/views/filament/central/resources/company/view.blade.php b/resources/views/filament/central/resources/company/view.blade.php new file mode 100644 index 0000000..92f83c3 --- /dev/null +++ b/resources/views/filament/central/resources/company/view.blade.php @@ -0,0 +1,239 @@ + + + + @php + $stats = $this->getStats(); + $daysLeft = $this->getDaysUntilExpiry(); + $statusColors = [ + 'active' => 'success', + 'trial' => 'warning', + 'suspended' => 'danger', + 'expired' => 'danger', + 'archived' => 'gray', + ]; + $statusLabel = match ($this->record->status) { + 'active' => 'Activ', + 'trial' => 'Trial', + 'suspended' => 'Suspendat', + 'expired' => 'Expirat', + 'archived' => 'Arhivat', + default => $this->record->status, + }; + @endphp + + {{-- HEADER --}} +
+ @if ($this->record->getLogoUrl()) + logo + @endif +
+

{{ $this->record->display_name ?? $this->record->name }}

+
+ {{ $this->record->slug }}.service.mir.md + @if ($this->record->city) · {{ $this->record->city }} @endif + @if ($this->record->phone) · {{ $this->record->phone }} @endif + @if ($this->record->email) · {{ $this->record->email }} @endif +
+
+ + {{ $statusLabel }} + + @if ($this->record->plan) + 📦 {{ $this->record->plan->name }} + @else + ⚠ Fără plan + @endif + @if ($daysLeft !== null) + @if ($daysLeft < 0) + Expirat acum {{ abs($daysLeft) }} zile + @elseif ($daysLeft < 7) + Expiră în {{ $daysLeft }} zile + @else + {{ $daysLeft }} zile rămase + @endif + @endif +
+
+
+ + {{-- STATS GRID --}} +
+
📊 Activitate live
+
+
+
Useri
+
{{ $stats['users'] }}
+
+
+
Clienți
+
{{ $stats['clients'] }}
+
+
+
Mașini
+
{{ $stats['vehicles'] }}
+
+
+
Fișe lucru
+
{{ $stats['work_orders'] }}
+
{{ $stats['work_orders_open'] }} deschise
+
+
+
Piese în stoc
+
{{ $stats['parts'] }}
+ @if ($stats['parts_low_stock']) +
⚠ {{ $stats['parts_low_stock'] }} sub minim
+ @endif +
+
+
Venit luna curentă
+
{{ number_format($stats['revenue_this_month'], 0, ',', ' ') }}
+
{{ $this->record->settings['currency'] ?? 'MDL' }}
+
+
+
Venit luna trecută
+
{{ number_format($stats['revenue_last_month'], 0, ',', ' ') }}
+
{{ $this->record->settings['currency'] ?? 'MDL' }}
+
+
+
Storage media
+
{{ $stats['storage_mb'] }}
+
MB folosiți
+
+
+
Ultima logare
+
+ {{ $stats['last_login'] ? \Carbon\Carbon::parse($stats['last_login'])->diffForHumans() : 'Niciodată' }} +
+ @if ($stats['last_login'] && \Carbon\Carbon::parse($stats['last_login'])->diffInDays() > 14) +
⚠ Posibil churn
+ @endif +
+
+
+ + {{-- SUBSCRIPTIONS --}} +
+
💳 Istoric abonamente
+ @if ($this->record->subscriptions->isEmpty()) +
+ Niciun abonament emis. Folosește „Generează factură" de sus. +
+ @else + + + + + + + + + + + + + @foreach ($this->record->subscriptions as $s) + + + + + + + + + @endforeach + +
FacturăPlanPerioadăSumăStatusPlătit
{{ $s->invoice_number ?? '—' }}{{ $s->plan?->name ?? '—' }}{{ $s->period_start?->format('d.m.Y') }} → {{ $s->period_end?->format('d.m.Y') }}{{ number_format($s->amount, 2) }} {{ $s->currency }} + + {{ \App\Models\Central\Subscription::STATUSES[$s->status] ?? $s->status }} + + {{ $s->paid_at?->format('d.m.Y') ?? '—' }}
+ @endif +
+ + {{-- CONFIG --}} +
+
⚙ Configurare tenant
+
+
+
Limbă
+
+ {{ ['ro' => 'Română', 'ru' => 'Русский', 'en' => 'English'][$this->record->settings['language'] ?? 'ro'] ?? '—' }} +
+
+
+
Monedă
+
{{ $this->record->settings['currency'] ?? 'MDL' }}
+
+
+
Tarif normo-oră
+
{{ $this->record->settings['labor_rate'] ?? '—' }}
+
+
+
Onboarded
+
+ {{ ! empty($this->record->settings['onboarded_at']) ? '✓ Da' : '✗ Nu' }} +
+
+
+
Theme color
+
+ + {{ $this->record->settings['theme_color'] ?? '#3B82F6' }} +
+
+
+
AI provider
+
+ {{ $this->record->settings['ai']['default_provider'] ?? 'neconfigurat' }} +
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 749eef8..6b11c19 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,25 @@ Route::post('/locale/{lang}', function (Request $request, string $lang) { return back(); })->name('locale.switch'); +// PWA — manifest pentru panou central (service.mir.md). +Route::get('/admin-manifest.json', function () { + return response()->json([ + 'name' => 'AutoCRM Admin', + 'short_name' => 'AutoCRM', + 'description' => 'Panou administrativ AutoCRM SaaS', + 'start_url' => '/admin', + 'display' => 'standalone', + 'orientation' => 'any', + 'background_color' => '#ffffff', + 'theme_color' => '#6366f1', + 'lang' => 'ro', + 'icons' => [ + ['src' => '/pwa/admin-192.png', 'sizes' => '192x192', 'type' => 'image/png'], + ['src' => '/pwa/admin-512.png', 'sizes' => '512x512', 'type' => 'image/png'], + ], + ])->header('Cache-Control', 'public, max-age=3600'); +}); + // PWA — manifest dinamic per tenant. Route::get('/manifest.json', function (Request $request) { $tenant = app(TenantManager::class)->current();