diff --git a/app/Filament/Central/Resources/CompanyResource.php b/app/Filament/Central/Resources/CompanyResource.php new file mode 100644 index 0000000..38c68a5 --- /dev/null +++ b/app/Filament/Central/Resources/CompanyResource.php @@ -0,0 +1,118 @@ +components([ + Forms\Components\Section::make('Identificare') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('slug') + ->required() + ->alphaDash() + ->lowercase() + ->maxLength(30) + ->unique(ignoreRecord: true) + ->helperText('Subdomeniul: .service.mir.md'), + Forms\Components\TextInput::make('name')->required()->maxLength(120), + Forms\Components\TextInput::make('display_name')->maxLength(120), + Forms\Components\TextInput::make('city')->maxLength(60), + ]), + Forms\Components\Section::make('Contact') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('contact_name')->maxLength(120), + Forms\Components\TextInput::make('phone')->tel()->maxLength(40), + Forms\Components\TextInput::make('email')->email()->maxLength(120), + ]), + Forms\Components\Section::make('Abonament') + ->columns(2) + ->schema([ + Forms\Components\Select::make('status') + ->options([ + 'trial' => 'Trial', + 'active' => 'Activ', + 'expired' => 'Expirat', + 'suspended' => 'Suspendat', + 'archived' => 'Arhivat', + ]) + ->default('trial') + ->required(), + Forms\Components\Select::make('plan_id') + ->label('Plan') + ->options(fn () => Plan::pluck('name', 'id')) + ->searchable(), + Forms\Components\DateTimePicker::make('trial_ends_at')->label('Trial expiră la'), + Forms\Components\DateTimePicker::make('active_until')->label('Abonament până la'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id')->sortable(), + Tables\Columns\TextColumn::make('slug') + ->searchable() + ->copyable() + ->url(fn (Company $r) => $r->url('/app')) + ->openUrlInNewTab(), + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->colors([ + 'success' => ['active'], + 'warning' => ['trial'], + 'danger' => ['suspended', 'expired'], + 'gray' => ['archived'], + ]), + Tables\Columns\TextColumn::make('plan.name')->label('Plan')->placeholder('—'), + Tables\Columns\TextColumn::make('city')->toggleable(), + Tables\Columns\TextColumn::make('users_count')->counts('users')->label('Useri'), + Tables\Columns\TextColumn::make('created_at')->date()->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status')->options([ + 'trial' => 'Trial', 'active' => 'Activ', 'expired' => 'Expirat', + 'suspended' => 'Suspendat', 'archived' => 'Arhivat', + ]), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCompanies::route('/'), + 'create' => Pages\CreateCompany::route('/create'), + 'edit' => Pages\EditCompany::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Central/Resources/CompanyResource/Pages/CreateCompany.php b/app/Filament/Central/Resources/CompanyResource/Pages/CreateCompany.php new file mode 100644 index 0000000..d361934 --- /dev/null +++ b/app/Filament/Central/Resources/CompanyResource/Pages/CreateCompany.php @@ -0,0 +1,11 @@ +components([ + Forms\Components\Section::make('Date generale') + ->columns(2) + ->schema([ + Forms\Components\Select::make('type') + ->label('Tip') + ->options(['individual' => 'Persoană fizică', 'company' => 'Persoană juridică']) + ->default('individual') + ->required() + ->live(), + Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(120), + Forms\Components\TextInput::make('company_name') + ->label('Denumire companie') + ->visible(fn (Forms\Get $get) => $get('type') === 'company') + ->maxLength(160), + Forms\Components\Select::make('status') + ->options([ + 'new' => 'Nou', 'active' => 'Activ', 'vip' => 'VIP', + 'debtor' => 'Datornic', 'blocked' => 'Blocat', 'lost' => 'Pierdut', + ]) + ->default('active') + ->required(), + ]), + Forms\Components\Section::make('Contacte') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->required()->maxLength(40), + Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40), + Forms\Components\TextInput::make('email')->email()->maxLength(120), + Forms\Components\TextInput::make('telegram')->maxLength(60), + Forms\Components\TextInput::make('whatsapp')->maxLength(60), + Forms\Components\TextInput::make('viber')->maxLength(60), + ]), + Forms\Components\Section::make('Marketing') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('source')->label('Sursă')->maxLength(60), + Forms\Components\TextInput::make('marketing_channel')->label('Canal marketing')->maxLength(60), + ]), + Forms\Components\Section::make('Financiar') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('balance')->label('Sold')->numeric()->default(0), + Forms\Components\TextInput::make('discount_pct')->label('Discount %')->numeric()->default(0), + ]), + Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(3), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('phone')->searchable()->copyable(), + Tables\Columns\TextColumn::make('email')->searchable()->toggleable(), + Tables\Columns\TextColumn::make('vehicles_count')->counts('vehicles')->label('Mașini'), + Tables\Columns\TextColumn::make('status') + ->badge() + ->colors([ + 'success' => ['active', 'vip'], + 'gray' => ['new'], + 'danger' => ['debtor', 'blocked', 'lost'], + ]), + Tables\Columns\TextColumn::make('balance') + ->money(fn () => tenant()?->settings['currency'] ?? 'MDL') + ->color(fn ($state) => $state < 0 ? 'danger' : 'success'), + Tables\Columns\TextColumn::make('created_at')->date()->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status')->options([ + 'new' => 'Nou', 'active' => 'Activ', 'vip' => 'VIP', + 'debtor' => 'Datornic', 'blocked' => 'Blocat', 'lost' => 'Pierdut', + ]), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListClients::route('/'), + 'create' => Pages\CreateClient::route('/create'), + 'edit' => Pages\EditClient::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/ClientResource/Pages/CreateClient.php b/app/Filament/Tenant/Resources/ClientResource/Pages/CreateClient.php new file mode 100644 index 0000000..2c01985 --- /dev/null +++ b/app/Filament/Tenant/Resources/ClientResource/Pages/CreateClient.php @@ -0,0 +1,11 @@ +components([ + Forms\Components\Section::make('Identificare') + ->columns(2) + ->schema([ + Forms\Components\Select::make('client_id') + ->label('Proprietar') + ->options(fn () => Client::pluck('name', 'id')) + ->searchable() + ->required(), + Forms\Components\TextInput::make('plate')->label('Nr. înmatriculare')->maxLength(16), + Forms\Components\TextInput::make('make')->label('Marca')->required()->maxLength(60), + Forms\Components\TextInput::make('model')->required()->maxLength(60), + Forms\Components\TextInput::make('year')->numeric()->minValue(1950)->maxValue(2100), + Forms\Components\TextInput::make('vin')->maxLength(32), + ]), + Forms\Components\Section::make('Tehnice') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('engine')->maxLength(60), + Forms\Components\TextInput::make('gearbox')->maxLength(60), + Forms\Components\Select::make('fuel') + ->options([ + 'Benzină' => 'Benzină', 'Diesel' => 'Diesel', 'Hybrid' => 'Hybrid', + 'EV' => 'Electric', 'GPL' => 'GPL', 'GNC' => 'GNC', + ]), + Forms\Components\TextInput::make('mileage')->label('Kilometraj')->numeric()->default(0), + Forms\Components\TextInput::make('color')->maxLength(40), + ]), + Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(3), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('plate')->label('Nr.')->searchable(), + Tables\Columns\TextColumn::make('make')->sortable(), + Tables\Columns\TextColumn::make('model'), + Tables\Columns\TextColumn::make('year'), + Tables\Columns\TextColumn::make('client.name')->label('Proprietar')->searchable(), + Tables\Columns\TextColumn::make('vin')->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('mileage')->label('Km')->numeric(), + Tables\Columns\TextColumn::make('created_at')->date()->sortable(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListVehicles::route('/'), + 'create' => Pages\CreateVehicle::route('/create'), + 'edit' => Pages\EditVehicle::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/VehicleResource/Pages/CreateVehicle.php b/app/Filament/Tenant/Resources/VehicleResource/Pages/CreateVehicle.php new file mode 100644 index 0000000..617f116 --- /dev/null +++ b/app/Filament/Tenant/Resources/VehicleResource/Pages/CreateVehicle.php @@ -0,0 +1,11 @@ +current(); + + if (! $company) { + return $next($request); // central context + } + + return match ($company->status) { + 'archived' => response('Cont arhivat.', 410), + 'suspended' => response()->view('tenant.suspended', ['company' => $company], 423), + 'expired' => response()->view('tenant.expired', ['company' => $company], 402), + default => $next($request), + }; + } +} diff --git a/app/Http/Middleware/ResolveTenant.php b/app/Http/Middleware/ResolveTenant.php new file mode 100644 index 0000000..e918189 --- /dev/null +++ b/app/Http/Middleware/ResolveTenant.php @@ -0,0 +1,69 @@ +getHost(); + $central = config('tenancy.central_domains', []); + $centralPrimary = config('app.central_domain') ?: $central[0] ?? 'service.mir.md'; + + // No subdomain → central context. + if (in_array($host, $central, true)) { + // Tenant panel must never run on the central host. + if (str_starts_with($request->path(), 'app')) { + throw new NotFoundHttpException('Tenant routes are not available on the central domain.'); + } + return $next($request); + } + + $slug = null; + if (str_ends_with($host, ".{$centralPrimary}")) { + $slug = substr($host, 0, -strlen(".{$centralPrimary}")); + } + + if (! $slug || in_array($slug, self::RESERVED, true) || str_contains($slug, '.')) { + // Reserved or multi-level subdomain → not a tenant. 404. + throw new NotFoundHttpException("Unknown subdomain: {$host}"); + } + + $company = Company::where('slug', $slug)->first(); + + if (! $company) { + throw new NotFoundHttpException("Tenant '{$slug}' not found."); + } + + app(TenantManager::class)->setCurrent($company); + $request->attributes->set('tenant', $company); + + if ($required === 'required' && ! app(TenantManager::class)->isResolved()) { + throw new NotFoundHttpException(); + } + + return $next($request); + } +} diff --git a/app/Models/Central/Company.php b/app/Models/Central/Company.php new file mode 100644 index 0000000..26b5314 --- /dev/null +++ b/app/Models/Central/Company.php @@ -0,0 +1,81 @@ + 'array', + 'data' => 'array', + 'trial_ends_at' => 'datetime', + 'active_until' => 'datetime', + ]; + + /** Stancl expects this to know what columns are NOT in the JSON `data` blob. */ + public static function getCustomColumns(): array + { + return [ + 'id', + 'slug', 'name', 'display_name', 'city', 'phone', 'email', 'contact_name', + 'status', 'plan_id', + 'trial_ends_at', 'active_until', + 'settings', + 'created_at', 'updated_at', 'deleted_at', + ]; + } + + public function plan() + { + return $this->belongsTo(Plan::class); + } + + public function users() + { + return $this->hasMany(User::class); + } + + public function isActive(): bool + { + return in_array($this->status, ['active', 'trial'], true); + } + + public function isAccessible(): bool + { + if ($this->status === 'archived' || $this->status === 'suspended') { + return false; + } + if ($this->status === 'expired') { + return false; + } + return true; + } + + /** Get the URL for this tenant. */ + public function url(?string $path = '/'): string + { + $central = config('app.central_domain') ?: 'service.mir.md'; + return "https://{$this->slug}.{$central}{$path}"; + } +} diff --git a/app/Models/Central/Plan.php b/app/Models/Central/Plan.php new file mode 100644 index 0000000..39e974e --- /dev/null +++ b/app/Models/Central/Plan.php @@ -0,0 +1,27 @@ + 'array', + 'limits' => 'array', + 'is_active' => 'boolean', + 'is_public' => 'boolean', + 'price_monthly' => 'decimal:2', + 'price_yearly' => 'decimal:2', + ]; + + public function companies() + { + return $this->hasMany(Company::class); + } +} diff --git a/app/Models/Central/SuperAdmin.php b/app/Models/Central/SuperAdmin.php new file mode 100644 index 0000000..842b672 --- /dev/null +++ b/app/Models/Central/SuperAdmin.php @@ -0,0 +1,40 @@ + 'datetime', + 'last_login_at' => 'datetime', + 'password' => 'hashed', + 'is_active' => 'boolean', + ]; + } + + public function canAccessPanel(Panel $panel): bool + { + return $panel->getId() === 'central' && $this->is_active; + } +} diff --git a/app/Models/Concerns/BelongsToTenant.php b/app/Models/Concerns/BelongsToTenant.php new file mode 100644 index 0000000..f796ce7 --- /dev/null +++ b/app/Models/Concerns/BelongsToTenant.php @@ -0,0 +1,36 @@ +company_id)) { + $tenant = app(TenantManager::class); + if ($tenant->isResolved()) { + $model->company_id = $tenant->currentId(); + } + } + }); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } +} diff --git a/app/Models/Scopes/TenantScope.php b/app/Models/Scopes/TenantScope.php new file mode 100644 index 0000000..50950b7 --- /dev/null +++ b/app/Models/Scopes/TenantScope.php @@ -0,0 +1,33 @@ +isResolved()) { + // Fail-safe: no tenant set → return zero rows (prevents accidental + // cross-tenant leak). Use withoutGlobalScopes() in central panel + // to query across all tenants intentionally. + $builder->whereRaw('0 = 1'); + return; + } + + $builder->where( + $model->getTable() . '.company_id', + $tenant->currentId() + ); + } +} diff --git a/app/Models/Tenant/Client.php b/app/Models/Tenant/Client.php new file mode 100644 index 0000000..c91849c --- /dev/null +++ b/app/Models/Tenant/Client.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'discount_pct' => 'decimal:2', + 'last_contact_at' => 'datetime', + ]; + + public function vehicles(): HasMany + { + return $this->hasMany(Vehicle::class); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } +} diff --git a/app/Models/Tenant/User.php b/app/Models/Tenant/User.php new file mode 100644 index 0000000..17d963d --- /dev/null +++ b/app/Models/Tenant/User.php @@ -0,0 +1,51 @@ + 'datetime', + 'last_login_at' => 'datetime', + 'password' => 'hashed', + ]; + } + + public function canAccessPanel(Panel $panel): bool + { + return $panel->getId() === 'tenant' + && $this->status === 'active'; + } + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } +} diff --git a/app/Models/Tenant/Vehicle.php b/app/Models/Tenant/Vehicle.php new file mode 100644 index 0000000..174dee7 --- /dev/null +++ b/app/Models/Tenant/Vehicle.php @@ -0,0 +1,29 @@ +belongsTo(Client::class); + } + + public function getDisplayNameAttribute(): string + { + return trim("{$this->make} {$this->model} " . ($this->year ?: '')); + } +} diff --git a/app/Models/User.php b/app/Models/User.php deleted file mode 100644 index 68f3a66..0000000 --- a/app/Models/User.php +++ /dev/null @@ -1,49 +0,0 @@ - */ - use HasFactory, Notifiable; - - /** - * The attributes that are mass assignable. - * - * @var list - */ - protected $fillable = [ - 'name', - 'email', - 'password', - ]; - - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ - protected $hidden = [ - 'password', - 'remember_token', - ]; - - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..a80bbf8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,23 +2,23 @@ namespace App\Providers; +use App\Tenancy\TenantManager; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { - // + $this->app->singleton(TenantManager::class); } - /** - * Bootstrap any application services. - */ public function boot(): void { - // + // Behind a TLS-terminating proxy (Cloudflare → Coolify Traefik → Octane). + if (! $this->app->runningInConsole() && (str_starts_with(config('app.url'), 'https://') || env('FORCE_HTTPS'))) { + URL::forceScheme('https'); + URL::forceRootUrl(config('app.url')); + } } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/CentralPanelProvider.php similarity index 65% rename from app/Providers/Filament/AdminPanelProvider.php rename to app/Providers/Filament/CentralPanelProvider.php index 8ce4eb0..d4580a5 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/CentralPanelProvider.php @@ -10,8 +10,6 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; -use Filament\Widgets\AccountWidget; -use Filament\Widgets\FilamentInfoWidget; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; @@ -19,28 +17,32 @@ use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; -class AdminPanelProvider extends PanelProvider +/** + * Central panel — only on the central domain (service.mir.md/admin). + * Uses the 'central' auth guard (super_admins). + */ +class CentralPanelProvider extends PanelProvider { public function panel(Panel $panel): Panel { return $panel ->default() - ->id('admin') + ->id('central') ->path('admin') + ->domain(env('CENTRAL_DOMAIN', 'service.mir.md')) ->login() + ->brandName('AutoCRM — Platformă') ->colors([ - 'primary' => Color::Amber, + 'primary' => Color::Indigo, ]) - ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') - ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') + ->authGuard('central') + ->authPasswordBroker('super_admins') + ->discoverResources(in: app_path('Filament/Central/Resources'), for: 'App\\Filament\\Central\\Resources') + ->discoverPages(in: app_path('Filament/Central/Pages'), for: 'App\\Filament\\Central\\Pages') ->pages([ Dashboard::class, ]) - ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') - ->widgets([ - AccountWidget::class, - FilamentInfoWidget::class, - ]) + ->discoverWidgets(in: app_path('Filament/Central/Widgets'), for: 'App\\Filament\\Central\\Widgets') ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php new file mode 100644 index 0000000..999eddc --- /dev/null +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -0,0 +1,62 @@ +.service.mir.md. + * ResolveTenant middleware loads the current Company before any auth check. + */ +class TenantPanelProvider extends PanelProvider +{ + public function panel(Panel $panel): Panel + { + return $panel + ->id('tenant') + ->path('app') + ->login() + ->brandName('AutoCRM') + ->colors([ + 'primary' => Color::Blue, + ]) + ->authGuard('web') + ->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources') + ->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages') + ->pages([ + Dashboard::class, + ]) + ->discoverWidgets(in: app_path('Filament/Tenant/Widgets'), for: 'App\\Filament\\Tenant\\Widgets') + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ResolveTenant::class, + CheckTenantStatus::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]); + } +} diff --git a/app/Tenancy/TenantManager.php b/app/Tenancy/TenantManager.php new file mode 100644 index 0000000..bf4b0cd --- /dev/null +++ b/app/Tenancy/TenantManager.php @@ -0,0 +1,44 @@ +setCurrent($company); + * tenant() // returns Company|null + */ +class TenantManager +{ + protected ?Company $current = null; + + public function setCurrent(?Company $company): void + { + $this->current = $company; + } + + public function current(): ?Company + { + return $this->current; + } + + public function currentId(): ?int + { + return $this->current?->id; + } + + public function isResolved(): bool + { + return $this->current !== null; + } + + public function clear(): void + { + $this->current = null; + } +} diff --git a/app/helpers.php b/app/helpers.php new file mode 100644 index 0000000..78740cb --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,11 @@ +current(); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..b4c7ca7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -11,7 +12,15 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + // Trust Cloudflare + Coolify Traefik so HTTPS scheme is detected + // and X-Forwarded-* headers are honored. + $middleware->trustProxies(at: '*', headers: + Request::HEADER_X_FORWARDED_FOR + | Request::HEADER_X_FORWARDED_HOST + | Request::HEADER_X_FORWARDED_PORT + | Request::HEADER_X_FORWARDED_PROTO + | Request::HEADER_X_FORWARDED_AWS_ELB + ); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 22744d1..ac3e99c 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,5 +2,7 @@ return [ App\Providers\AppServiceProvider::class, - App\Providers\Filament\AdminPanelProvider::class, + App\Providers\TenancyServiceProvider::class, + App\Providers\Filament\CentralPanelProvider::class, + App\Providers\Filament\TenantPanelProvider::class, ]; diff --git a/composer.json b/composer.json index 0609279..9896b95 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,10 @@ "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" - } + }, + "files": [ + "app/helpers.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/config/auth.php b/config/auth.php index d7568ff..d9f7be5 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,97 +1,40 @@ [ 'guard' => env('AUTH_GUARD', 'web'), 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), ], - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - 'guards' => [ + // Tenant-side auth (per-company users on .service.mir.md). 'web' => [ 'driver' => 'session', 'provider' => 'users', ], - ], - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ + // Central-side auth (super-admins on service.mir.md/admin). + 'central' => [ + 'driver' => 'session', + 'provider' => 'super_admins', + ], + ], 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => env('AUTH_MODEL', User::class), ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + 'super_admins' => [ + 'driver' => 'eloquent', + 'model' => SuperAdmin::class, + ], ], - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - 'passwords' => [ 'users' => [ 'provider' => 'users', @@ -99,19 +42,14 @@ return [ 'expire' => 60, 'throttle' => 60, ], + 'super_admins' => [ + 'provider' => 'super_admins', + 'table' => 'password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the number of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), ]; diff --git a/config/tenancy.php b/config/tenancy.php index 387ef00..de7c46e 100644 --- a/config/tenancy.php +++ b/config/tenancy.php @@ -2,37 +2,35 @@ declare(strict_types=1); +use App\Models\Central\Company; use Stancl\Tenancy\Database\Models\Domain; -use Stancl\Tenancy\Database\Models\Tenant; return [ - 'tenant_model' => Tenant::class, - 'id_generator' => Stancl\Tenancy\UUIDGenerator::class, + // We use our own Company as the tenant model (single DB, slug-based identification). + 'tenant_model' => Company::class, + 'id_generator' => null, // We use auto-increment IDs, not UUIDs. 'domain_model' => Domain::class, /** * The list of domains hosting your central app. - * - * Only relevant if you're using the domain or subdomain identification middleware. */ 'central_domains' => [ + env('CENTRAL_DOMAIN', 'service.mir.md'), '127.0.0.1', 'localhost', ], /** - * Tenancy bootstrappers are executed when tenancy is initialized. - * Their responsibility is making Laravel features tenant-aware. - * - * To configure their behavior, see the config keys below. + * Single-database mode: NO bootstrappers active. + * We rely on Eloquent global scopes via App\Models\Concerns\BelongsToTenant + * for data isolation. Cache/queue scoping handled manually if/when needed. */ 'bootstrappers' => [ - Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class, - Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class, - Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class, - Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, - // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed + // Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class, // multi-DB only + // Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class, + // Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class, + // Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, ], /** diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php deleted file mode 100644 index c4ceb07..0000000 --- a/database/factories/UserFactory.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -class UserFactory extends Factory -{ - /** - * The current password being used by the factory. - */ - protected static ?string $password; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), - 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), - ]; - } - - /** - * Indicate that the model's email address should be unverified. - */ - public function unverified(): static - { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, - ]); - } -} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php deleted file mode 100644 index 05fb5d9..0000000 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ /dev/null @@ -1,49 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - - Schema::create('password_reset_tokens', function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - - Schema::create('sessions', function (Blueprint $table) { - $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('users'); - Schema::dropIfExists('password_reset_tokens'); - Schema::dropIfExists('sessions'); - } -}; diff --git a/database/migrations/2019_09_15_000010_create_tenants_table.php b/database/migrations/2019_09_15_000010_create_tenants_table.php deleted file mode 100644 index ec73065..0000000 --- a/database/migrations/2019_09_15_000010_create_tenants_table.php +++ /dev/null @@ -1,37 +0,0 @@ -string('id')->primary(); - - // your custom columns may go here - - $table->timestamps(); - $table->json('data')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down(): void - { - Schema::dropIfExists('tenants'); - } -} diff --git a/database/migrations/2019_09_15_000020_create_domains_table.php b/database/migrations/2019_09_15_000020_create_domains_table.php deleted file mode 100644 index 77c1b88..0000000 --- a/database/migrations/2019_09_15_000020_create_domains_table.php +++ /dev/null @@ -1,37 +0,0 @@ -increments('id'); - $table->string('domain', 255)->unique(); - $table->string('tenant_id'); - - $table->timestamps(); - $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down(): void - { - Schema::dropIfExists('domains'); - } -} diff --git a/database/migrations/2020_05_15_000010_create_tenant_user_impersonation_tokens_table.php b/database/migrations/2020_05_15_000010_create_tenant_user_impersonation_tokens_table.php deleted file mode 100644 index 32597f3..0000000 --- a/database/migrations/2020_05_15_000010_create_tenant_user_impersonation_tokens_table.php +++ /dev/null @@ -1,39 +0,0 @@ -string('token', 128)->primary(); - $table->string('tenant_id'); - $table->string('user_id'); - $table->string('auth_guard'); - $table->string('redirect_url'); - $table->timestamp('created_at'); - - $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down(): void - { - Schema::dropIfExists('tenant_user_impersonation_tokens'); - } -} diff --git a/database/migrations/2026_05_05_000001_create_plans_table.php b/database/migrations/2026_05_05_000001_create_plans_table.php new file mode 100644 index 0000000..364a368 --- /dev/null +++ b/database/migrations/2026_05_05_000001_create_plans_table.php @@ -0,0 +1,30 @@ +id(); + $t->string('slug')->unique(); + $t->string('name'); + $t->decimal('price_monthly', 10, 2)->default(0); + $t->decimal('price_yearly', 10, 2)->default(0); + $t->string('currency', 3)->default('MDL'); + $t->json('features')->nullable(); // ['kanban', 'reports', 'ai', ...] + $t->json('limits')->nullable(); // ['max_users' => 5, 'max_vehicles' => 100, ...] + $t->boolean('is_active')->default(true); + $t->boolean('is_public')->default(true); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('plans'); + } +}; diff --git a/database/migrations/2026_05_05_000002_create_companies_table.php b/database/migrations/2026_05_05_000002_create_companies_table.php new file mode 100644 index 0000000..917e4f1 --- /dev/null +++ b/database/migrations/2026_05_05_000002_create_companies_table.php @@ -0,0 +1,44 @@ +id(); + $t->string('slug')->unique(); // psauto, autoplus, ... + $t->string('name'); // PSauto SRL + $t->string('display_name')->nullable(); // shown in UI + $t->string('city')->nullable(); + $t->string('phone')->nullable(); + $t->string('email')->nullable(); + $t->string('contact_name')->nullable(); + + $t->enum('status', ['trial', 'active', 'expired', 'suspended', 'archived']) + ->default('trial'); + + $t->foreignId('plan_id')->nullable()->constrained()->nullOnDelete(); + $t->timestamp('trial_ends_at')->nullable(); + $t->timestamp('active_until')->nullable(); + + // White-label settings (preluat din cfg-ul prototipului AutoCRM.html). + $t->json('settings')->nullable(); + // Compatibilitate cu stancl/tenancy v3 — stochează metadate libere. + $t->json('data')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('companies'); + } +}; diff --git a/database/migrations/2026_05_05_000003_create_super_admins_table.php b/database/migrations/2026_05_05_000003_create_super_admins_table.php new file mode 100644 index 0000000..886a40d --- /dev/null +++ b/database/migrations/2026_05_05_000003_create_super_admins_table.php @@ -0,0 +1,28 @@ +id(); + $t->string('name'); + $t->string('email')->unique(); + $t->timestamp('email_verified_at')->nullable(); + $t->string('password'); + $t->boolean('is_active')->default(true); + $t->timestamp('last_login_at')->nullable(); + $t->rememberToken(); + $t->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('super_admins'); + } +}; diff --git a/database/migrations/2026_05_05_000010_create_users_table.php b/database/migrations/2026_05_05_000010_create_users_table.php new file mode 100644 index 0000000..9b6b4ed --- /dev/null +++ b/database/migrations/2026_05_05_000010_create_users_table.php @@ -0,0 +1,59 @@ +id(); + $t->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + + $t->string('name'); + $t->string('email'); + $t->string('phone')->nullable(); + $t->string('avatar_url')->nullable(); + + $t->string('role')->default('user'); // admin / manager / receptionist / mechanic / user + $t->string('status')->default('active'); // active / inactive / blocked + $t->string('locale', 5)->default('ro'); + + $t->timestamp('email_verified_at')->nullable(); + $t->string('password'); + $t->timestamp('last_login_at')->nullable(); + $t->rememberToken(); + $t->timestamps(); + $t->softDeletes(); + + // CRITIC: same email can exist across tenants but unique within tenant. + $t->unique(['company_id', 'email']); + $t->index(['company_id', 'role']); + $t->index(['company_id', 'status']); + }); + + Schema::create('password_reset_tokens', function (Blueprint $t) { + $t->string('email')->primary(); + $t->string('token'); + $t->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $t) { + $t->string('id')->primary(); + $t->foreignId('user_id')->nullable()->index(); + $t->string('ip_address', 45)->nullable(); + $t->text('user_agent')->nullable(); + $t->longText('payload'); + $t->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('users'); + } +}; diff --git a/database/migrations/2026_05_05_000020_create_clients_table.php b/database/migrations/2026_05_05_000020_create_clients_table.php new file mode 100644 index 0000000..1b63cf8 --- /dev/null +++ b/database/migrations/2026_05_05_000020_create_clients_table.php @@ -0,0 +1,49 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + + $t->string('type')->default('individual'); // individual / company + $t->string('name'); + $t->string('company_name')->nullable(); // for type=company + + $t->string('phone'); + $t->string('phone_alt')->nullable(); + $t->string('email')->nullable(); + $t->string('telegram')->nullable(); + $t->string('whatsapp')->nullable(); + $t->string('viber')->nullable(); + + $t->string('source')->nullable(); // call / site / telegram / google / ... + $t->string('marketing_channel')->nullable(); + $t->string('status')->default('active'); // new / active / vip / debtor / blocked / lost + + $t->decimal('balance', 12, 2)->default(0); // negative = customer owes us + $t->decimal('discount_pct', 5, 2)->default(0); + $t->text('notes')->nullable(); + + $t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $t->timestamp('last_contact_at')->nullable(); + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'status']); + $t->index(['company_id', 'phone']); + $t->index(['company_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('clients'); + } +}; diff --git a/database/migrations/2026_05_05_000021_create_vehicles_table.php b/database/migrations/2026_05_05_000021_create_vehicles_table.php new file mode 100644 index 0000000..870b00b --- /dev/null +++ b/database/migrations/2026_05_05_000021_create_vehicles_table.php @@ -0,0 +1,41 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('client_id')->constrained()->cascadeOnDelete(); + + $t->string('make'); // BMW, Audi, ... + $t->string('model'); // X5, A4, ... + $t->smallInteger('year')->nullable(); + $t->string('vin', 32)->nullable(); + $t->string('plate', 16)->nullable(); + + $t->string('engine')->nullable(); // 3.0i / 2.0 TDI + $t->string('gearbox')->nullable(); + $t->string('fuel')->nullable(); + $t->unsignedInteger('mileage')->default(0); + $t->string('color')->nullable(); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'client_id']); + $t->index(['company_id', 'plate']); + }); + } + + public function down(): void + { + Schema::dropIfExists('vehicles'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..168f7f1 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,24 +2,138 @@ namespace Database\Seeders; -use App\Models\User; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; +use App\Models\Central\Company; +use App\Models\Central\Plan; +use App\Models\Central\SuperAdmin; +use App\Models\Tenant\Client; +use App\Models\Tenant\User; +use App\Models\Tenant\Vehicle; +use App\Tenancy\TenantManager; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder { - use WithoutModelEvents; - - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + // ─── Plans ──────────────────────────────────────────────── + $free = Plan::firstOrCreate(['slug' => 'free'], [ + 'name' => 'Free', + 'price_monthly' => 0, + 'currency' => 'MDL', + 'features' => ['core'], + 'limits' => ['max_users' => 2, 'max_vehicles' => 50], ]); + + Plan::firstOrCreate(['slug' => 'basic'], [ + 'name' => 'Basic', + 'price_monthly' => 299, + 'currency' => 'MDL', + 'features' => ['core', 'workorders', 'kanban'], + 'limits' => ['max_users' => 5, 'max_vehicles' => 500], + ]); + + Plan::firstOrCreate(['slug' => 'pro'], [ + 'name' => 'Pro', + 'price_monthly' => 599, + 'currency' => 'MDL', + 'features' => ['core', 'workorders', 'kanban', 'reports', 'ai', 'api'], + 'limits' => ['max_users' => -1, 'max_vehicles' => -1], + ]); + + // ─── Super-admin (operator platformă) ───────────────────── + SuperAdmin::firstOrCreate(['email' => 'vasyka.moraru@gmail.com'], [ + 'name' => 'Vasyka', + 'password' => Hash::make('admin123'), + 'is_active' => true, + ]); + + // ─── PSauto demo company ────────────────────────────────── + $psauto = Company::firstOrCreate(['slug' => 'psauto'], [ + 'name' => 'PSauto SRL', + 'display_name' => 'PSauto', + 'city' => 'Chișinău', + 'phone' => '+373 22 123 456', + 'email' => 'contact@psauto.md', + 'contact_name' => 'Manager PSauto', + 'status' => 'active', + 'plan_id' => $free->id, + 'active_until' => now()->addYear(), + 'settings' => [ + 'currency' => 'MDL', + 'language' => 'ro', + 'theme_color' => '#3B82F6', + 'labor_rate' => 400, + 'posts' => ['Post 1', 'Post 2', 'Post 3'], + ], + ]); + + // Activate tenant context for the seeded data so global scopes auto-fill company_id. + app(TenantManager::class)->setCurrent($psauto); + + // ─── Admin user pentru PSauto ───────────────────────────── + User::firstOrCreate( + ['company_id' => $psauto->id, 'email' => 'admin@psauto.md'], + [ + 'name' => 'Administrator PSauto', + 'password' => Hash::make('admin123'), + 'role' => 'admin', + 'status' => 'active', + 'phone' => '+373 22 123 456', + 'locale' => 'ro', + 'email_verified_at' => now(), + ] + ); + + // ─── Clienți demo (din AutoCRM.html) ────────────────────── + $c1 = Client::firstOrCreate( + ['company_id' => $psauto->id, 'phone' => '+373 69 100001'], + [ + 'type' => 'individual', + 'name' => 'Ion Popescu', + 'email' => 'ion@mail.com', + 'status' => 'vip', + 'source' => 'recommend', + ] + ); + + $c2 = Client::firstOrCreate( + ['company_id' => $psauto->id, 'phone' => '+373 79 200002'], + [ + 'type' => 'individual', + 'name' => 'Maria Dumitru', + 'status' => 'active', + 'discount_pct' => 5, + 'source' => 'site', + ] + ); + + $c3 = Client::firstOrCreate( + ['company_id' => $psauto->id, 'phone' => '+373 60 300003'], + [ + 'type' => 'individual', + 'name' => 'Andrei Lupu', + 'status' => 'active', + 'discount_pct' => 10, + 'notes' => 'Doar piese originale', + 'source' => 'instagram', + ] + ); + + // ─── Mașini demo ────────────────────────────────────────── + Vehicle::firstOrCreate( + ['company_id' => $psauto->id, 'client_id' => $c1->id, 'make' => 'BMW', 'model' => 'X5'], + ['year' => 2020, 'plate' => 'CIU 001', 'engine' => '3.0i', 'gearbox' => 'Automat 8AT', 'fuel' => 'Benzină', 'mileage' => 85000, 'color' => 'Alb'] + ); + Vehicle::firstOrCreate( + ['company_id' => $psauto->id, 'client_id' => $c2->id, 'make' => 'Audi', 'model' => 'A4'], + ['year' => 2019, 'plate' => 'CIU 002', 'engine' => '2.0 TDI', 'gearbox' => 'DSG7', 'fuel' => 'Diesel', 'mileage' => 45000, 'color' => 'Negru'] + ); + Vehicle::firstOrCreate( + ['company_id' => $psauto->id, 'client_id' => $c3->id, 'make' => 'Porsche', 'model' => 'Cayenne'], + ['year' => 2021, 'plate' => 'CIU 003', 'engine' => '3.0 TDI', 'gearbox' => 'Tiptronic', 'fuel' => 'Diesel', 'mileage' => 22000, 'color' => 'Gri'] + ); + + app(TenantManager::class)->clear(); } } diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index fb2901d..7549c97 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -13,6 +13,12 @@ if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then php artisan migrate --force --no-interaction || echo "[entrypoint] migrate failed (non-fatal)" fi +# Run seeders if requested. Uses firstOrCreate so it's idempotent. +if [ "${RUN_SEED:-false}" = "true" ]; then + echo "[entrypoint] Running database seed..." + php artisan db:seed --force --no-interaction || echo "[entrypoint] seed failed (non-fatal)" +fi + # Production caches if [ "${APP_ENV:-production}" = "production" ]; then echo "[entrypoint] Caching config/routes/views..." diff --git a/resources/views/tenant/expired.blade.php b/resources/views/tenant/expired.blade.php new file mode 100644 index 0000000..06212a8 --- /dev/null +++ b/resources/views/tenant/expired.blade.php @@ -0,0 +1,21 @@ + + + + + + {{ $company->name ?? 'Cont' }} — Abonament expirat + + + +
+

⏰ Abonament expirat

+

Abonamentul pentru {{ $company->name }} a expirat.

+

Pentru a continua să folosiți aplicația, vă rugăm să achitați factura.

+
+ + diff --git a/resources/views/tenant/suspended.blade.php b/resources/views/tenant/suspended.blade.php new file mode 100644 index 0000000..967a632 --- /dev/null +++ b/resources/views/tenant/suspended.blade.php @@ -0,0 +1,21 @@ + + + + + + {{ $company->name ?? 'Cont' }} — Cont suspendat + + + +
+

⛔ Cont suspendat

+

Contul {{ $company->name }} a fost suspendat.

+

Pentru detalii, contactați administratorul platformei.

+
+ +