diff --git a/app/Filament/Central/Widgets/PlatformStats.php b/app/Filament/Central/Widgets/PlatformStats.php new file mode 100644 index 0000000..91a87b5 --- /dev/null +++ b/app/Filament/Central/Widgets/PlatformStats.php @@ -0,0 +1,39 @@ +count(); + $trial = Company::where('status', 'trial')->count(); + $expiring = Company::where('status', 'active') + ->whereNotNull('active_until') + ->whereDate('active_until', '<=', now()->addDays(7)) + ->count(); + + return [ + Stat::make('Companii total', $total) + ->icon('heroicon-o-building-office-2') + ->color('primary'), + Stat::make('Active', $active) + ->icon('heroicon-o-check-circle') + ->color('success'), + Stat::make('Trial', $trial) + ->icon('heroicon-o-clock') + ->color('warning'), + Stat::make('Expiră în 7 zile', $expiring) + ->description($expiring > 0 ? 'Atenție!' : 'Toate ok') + ->icon('heroicon-o-exclamation-triangle') + ->color($expiring > 0 ? 'danger' : 'success'), + ]; + } +} diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php new file mode 100644 index 0000000..2685692 --- /dev/null +++ b/app/Filament/Tenant/Pages/Settings.php @@ -0,0 +1,109 @@ +current(); + $settings = (array) ($company->settings ?? []); + + $this->data = [ + 'display_name' => $company->display_name ?? $company->name, + 'city' => $company->city, + 'phone' => $company->phone, + 'email' => $company->email, + 'currency' => $settings['currency'] ?? 'MDL', + 'language' => $settings['language'] ?? 'ro', + 'theme_color' => $settings['theme_color'] ?? '#3B82F6', + 'labor_rate' => $settings['labor_rate'] ?? 400, + 'services' => isset($settings['services']) ? implode(', ', (array) $settings['services']) : '', + 'cars' => isset($settings['cars']) ? implode(', ', (array) $settings['cars']) : '', + ]; + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + Forms\Components\Section::make('Brand & contact') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('display_name')->label('Denumire afișată')->maxLength(120), + Forms\Components\TextInput::make('city')->label('Oraș')->maxLength(60), + Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->maxLength(40), + Forms\Components\TextInput::make('email')->email()->maxLength(120), + ]), + Forms\Components\Section::make('Localizare & monedă') + ->columns(3) + ->schema([ + Forms\Components\Select::make('language') + ->label('Limbă default') + ->options(['ro' => 'Română', 'ru' => 'Русский', 'en' => 'English']) + ->required(), + Forms\Components\TextInput::make('currency')->label('Monedă')->maxLength(8)->required(), + Forms\Components\ColorPicker::make('theme_color')->label('Culoare brand'), + ]), + Forms\Components\Section::make('Servicii & tarif') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('labor_rate')->label('Tarif normo-oră')->numeric()->required(), + ]), + Forms\Components\Section::make('Liste configurabile') + ->columns(1) + ->schema([ + Forms\Components\Textarea::make('services') + ->label('Servicii oferite (separate prin virgulă)') + ->rows(2), + Forms\Components\Textarea::make('cars') + ->label('Mărci auto suportate (separate prin virgulă)') + ->rows(2), + ]), + ]) + ->statePath('data'); + } + + public function save(): void + { + $data = $this->form->getState(); + $company = app(TenantManager::class)->current(); + + $company->update([ + 'display_name' => $data['display_name'] ?? null, + 'city' => $data['city'] ?? null, + 'phone' => $data['phone'] ?? null, + 'email' => $data['email'] ?? null, + 'settings' => array_merge((array) $company->settings, [ + 'language' => $data['language'] ?? 'ro', + 'currency' => $data['currency'] ?? 'MDL', + 'theme_color' => $data['theme_color'] ?? '#3B82F6', + 'labor_rate' => (float) ($data['labor_rate'] ?? 400), + 'services' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['services'] ?? ''))))), + 'cars' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['cars'] ?? ''))))), + ]), + ]); + + Notification::make()->title('Setări salvate')->success()->send(); + } +} diff --git a/app/Filament/Tenant/Resources/AppointmentResource.php b/app/Filament/Tenant/Resources/AppointmentResource.php new file mode 100644 index 0000000..4f5d80a --- /dev/null +++ b/app/Filament/Tenant/Resources/AppointmentResource.php @@ -0,0 +1,124 @@ +components([ + Forms\Components\Section::make('Când & unde') + ->columns(3) + ->schema([ + Forms\Components\DatePicker::make('date')->label('Data')->default(today())->required(), + Forms\Components\TimePicker::make('time_start')->label('De la')->required()->seconds(false), + Forms\Components\TimePicker::make('time_end')->label('Până la')->required()->seconds(false), + Forms\Components\Select::make('post_id') + ->label('Pod') + ->options(fn () => Post::where('is_active', true)->orderBy('sort_order')->pluck('name', 'id')) + ->searchable(), + Forms\Components\Select::make('master_id') + ->label('Maistru / Mecanic') + ->options(fn () => User::pluck('name', 'id')) + ->searchable(), + Forms\Components\Select::make('status') + ->options(Appointment::STATUSES) + ->default('scheduled') + ->required(), + ]), + Forms\Components\Section::make('Client & Auto') + ->columns(2) + ->schema([ + Forms\Components\Select::make('client_id') + ->label('Client') + ->options(fn () => Client::pluck('name', 'id')) + ->searchable() + ->live(), + Forms\Components\Select::make('vehicle_id') + ->label('Auto') + ->options(fn (Forms\Get $get) => $get('client_id') + ? Vehicle::where('client_id', $get('client_id'))->pluck('plate', 'id') + : []) + ->searchable(), + ]), + Forms\Components\TextInput::make('title')->label('Subiect')->required()->maxLength(160), + Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('date')->label('Data')->date('d.m.Y')->sortable(), + Tables\Columns\TextColumn::make('time_start')->label('De la')->time('H:i'), + Tables\Columns\TextColumn::make('time_end')->label('Până la')->time('H:i'), + Tables\Columns\TextColumn::make('post.name')->label('Pod')->placeholder('—'), + Tables\Columns\TextColumn::make('title')->label('Subiect')->searchable()->limit(40), + Tables\Columns\TextColumn::make('client.name')->label('Client')->placeholder('—'), + Tables\Columns\TextColumn::make('vehicle.plate')->label('Auto')->placeholder('—'), + Tables\Columns\TextColumn::make('master.name')->label('Maistru')->placeholder('—'), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($state) => Appointment::STATUSES[$state] ?? $state) + ->badge() + ->colors([ + 'gray' => ['scheduled'], + 'warning' => ['arrived'], + 'success' => ['done'], + 'danger' => ['cancelled', 'no_show'], + ]), + ]) + ->filters([ + Tables\Filters\Filter::make('today') + ->label('Astăzi') + ->query(fn ($q) => $q->whereDate('date', today())), + Tables\Filters\Filter::make('upcoming') + ->label('Viitoare') + ->query(fn ($q) => $q->where('date', '>=', today())), + Tables\Filters\SelectFilter::make('status')->options(Appointment::STATUSES), + Tables\Filters\SelectFilter::make('post_id') + ->label('Pod') + ->options(fn () => Post::pluck('name', 'id')), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->defaultSort('date', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListAppointments::route('/'), + 'create' => Pages\CreateAppointment::route('/create'), + 'edit' => Pages\EditAppointment::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/AppointmentResource/Pages/CreateAppointment.php b/app/Filament/Tenant/Resources/AppointmentResource/Pages/CreateAppointment.php new file mode 100644 index 0000000..5f636ef --- /dev/null +++ b/app/Filament/Tenant/Resources/AppointmentResource/Pages/CreateAppointment.php @@ -0,0 +1,11 @@ +components([ + Forms\Components\Section::make('Detalii') + ->columns(2) + ->schema([ + Forms\Components\Select::make('client_id') + ->label('Client') + ->options(fn () => Client::pluck('name', 'id')) + ->searchable() + ->required(), + Forms\Components\Select::make('vehicle_id') + ->label('Auto') + ->options(fn (Forms\Get $get) => $get('client_id') + ? Vehicle::where('client_id', $get('client_id'))->pluck('plate', 'id') + : []) + ->searchable(), + Forms\Components\TextInput::make('name')->label('Subiect')->required()->maxLength(160), + Forms\Components\TextInput::make('price')->label('Valoare')->numeric()->default(0), + Forms\Components\Select::make('stage') + ->options(Deal::STAGES) + ->default('new') + ->required(), + Forms\Components\Select::make('source') + ->options(Lead::SOURCES) + ->searchable(), + Forms\Components\Select::make('assigned_to') + ->label('Responsabil') + ->options(fn () => User::pluck('name', 'id')) + ->searchable(), + ]), + Forms\Components\Textarea::make('note')->label('Notițe')->columnSpanFull()->rows(3), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id')->label('#')->sortable(), + Tables\Columns\TextColumn::make('name')->label('Subiect')->searchable()->limit(40), + Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable(), + Tables\Columns\TextColumn::make('vehicle.plate')->label('Auto')->placeholder('—'), + Tables\Columns\TextColumn::make('stage') + ->formatStateUsing(fn ($state) => Deal::STAGES[$state] ?? $state) + ->badge() + ->colors([ + 'gray' => ['new'], + 'info' => ['contact', 'agree'], + 'warning' => ['scheduled', 'arrived', 'in_work'], + 'success' => ['done'], + 'danger' => ['lost'], + ]), + Tables\Columns\TextColumn::make('price')->money('MDL')->sortable(), + Tables\Columns\TextColumn::make('source')->label('Sursă')->formatStateUsing(fn ($state) => Lead::SOURCES[$state] ?? $state)->placeholder('—'), + Tables\Columns\TextColumn::make('assignedTo.name')->label('Responsabil')->placeholder('—'), + Tables\Columns\TextColumn::make('created_at')->date()->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('stage')->options(Deal::STAGES), + Tables\Filters\SelectFilter::make('assigned_to') + ->label('Responsabil') + ->options(fn () => User::pluck('name', 'id')), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListDeals::route('/'), + 'create' => Pages\CreateDeal::route('/create'), + 'edit' => Pages\EditDeal::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/DealResource/Pages/CreateDeal.php b/app/Filament/Tenant/Resources/DealResource/Pages/CreateDeal.php new file mode 100644 index 0000000..c818fb5 --- /dev/null +++ b/app/Filament/Tenant/Resources/DealResource/Pages/CreateDeal.php @@ -0,0 +1,11 @@ +components([ + Forms\Components\Section::make('Contact') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(120), + Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->required()->maxLength(40), + Forms\Components\TextInput::make('email')->email()->maxLength(120), + Forms\Components\Select::make('status') + ->options(Lead::STATUSES) + ->default('new') + ->required(), + ]), + Forms\Components\Section::make('Auto') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('car')->label('Marca')->maxLength(60), + Forms\Components\TextInput::make('model')->maxLength(60), + ]), + Forms\Components\Textarea::make('message')->label('Mesaj client')->columnSpanFull()->rows(3), + Forms\Components\Section::make('Sursă & Atribuire') + ->columns(2) + ->schema([ + Forms\Components\Select::make('source') + ->options(Lead::SOURCES) + ->searchable() + ->default('manual'), + Forms\Components\Select::make('assigned_to') + ->label('Responsabil') + ->options(fn () => User::pluck('name', 'id')) + ->searchable(), + Forms\Components\TextInput::make('budget')->label('Buget')->numeric(), + ]), + Forms\Components\Section::make('Marketing (UTM)') + ->collapsed() + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('utm_source'), + Forms\Components\TextInput::make('utm_medium'), + Forms\Components\TextInput::make('utm_campaign'), + Forms\Components\TextInput::make('utm_term'), + Forms\Components\TextInput::make('utm_content'), + ]), + Forms\Components\Textarea::make('notes')->label('Notițe interne')->columnSpanFull()->rows(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('created_at')->label('Data')->dateTime('d.m.Y H:i')->sortable(), + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('phone')->copyable()->searchable(), + Tables\Columns\TextColumn::make('car')->label('Auto')->formatStateUsing(fn ($state, $record) => trim($state . ' ' . ($record->model ?? ''))), + Tables\Columns\TextColumn::make('source')->label('Sursă')->formatStateUsing(fn ($state) => Lead::SOURCES[$state] ?? $state)->badge(), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($state) => Lead::STATUSES[$state] ?? $state) + ->badge() + ->colors([ + 'gray' => ['new'], + 'warning' => ['contacted', 'no_answer'], + 'info' => ['scheduled'], + 'success' => ['converted'], + 'danger' => ['lost'], + ]), + Tables\Columns\TextColumn::make('assignedTo.name')->label('Responsabil')->placeholder('—'), + Tables\Columns\TextColumn::make('budget')->money('MDL')->placeholder('—'), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status')->options(Lead::STATUSES), + Tables\Filters\SelectFilter::make('source')->options(Lead::SOURCES), + ]) + ->actions([ + Tables\Actions\Action::make('convert') + ->label('Convertește') + ->icon('heroicon-m-arrow-right-circle') + ->color('success') + ->visible(fn (Lead $r) => $r->status !== 'converted') + ->requiresConfirmation() + ->action(function (Lead $r) { + $deal = $r->convert(); + Notification::make() + ->title('Convertit în deal #' . $deal->id) + ->success() + ->send(); + }), + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListLeads::route('/'), + 'create' => Pages\CreateLead::route('/create'), + 'edit' => Pages\EditLead::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/LeadResource/Pages/CreateLead.php b/app/Filament/Tenant/Resources/LeadResource/Pages/CreateLead.php new file mode 100644 index 0000000..955e7dd --- /dev/null +++ b/app/Filament/Tenant/Resources/LeadResource/Pages/CreateLead.php @@ -0,0 +1,11 @@ +user(); + return $u && $u->role === 'admin'; + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Forms\Components\Section::make('Identitate') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(120), + Forms\Components\TextInput::make('email')->email()->required()->maxLength(120), + Forms\Components\TextInput::make('phone')->tel()->maxLength(40), + Forms\Components\Select::make('locale') + ->options(['ro' => 'Română', 'ru' => 'Русский', 'en' => 'English']) + ->default('ro'), + ]), + Forms\Components\Section::make('Acces') + ->columns(2) + ->schema([ + Forms\Components\Select::make('role') + ->label('Rol primar') + ->options([ + 'admin' => 'Administrator', + 'manager' => 'Manager', + 'receptionist' => 'Recepție', + 'mechanic' => 'Mecanic', + 'parts_manager' => 'Magazioner piese', + 'accountant' => 'Contabil', + 'marketer' => 'Marketing', + ]) + ->required() + ->default('mechanic'), + Forms\Components\Select::make('status') + ->options(['active' => 'Activ', 'inactive' => 'Inactiv', 'blocked' => 'Blocat']) + ->default('active') + ->required(), + Forms\Components\TextInput::make('password') + ->label('Parolă') + ->password() + ->required(fn (string $context) => $context === 'create') + ->dehydrated(fn ($state) => filled($state)) + ->dehydrateStateUsing(fn ($state) => Hash::make($state)) + ->minLength(6) + ->helperText('La editare lasă gol pentru a păstra parola actuală.'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('email')->searchable()->copyable(), + Tables\Columns\TextColumn::make('phone')->placeholder('—'), + Tables\Columns\TextColumn::make('role')->badge(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->colors([ + 'success' => ['active'], + 'warning' => ['inactive'], + 'danger' => ['blocked'], + ]), + Tables\Columns\TextColumn::make('last_login_at')->dateTime()->placeholder('—')->toggleable(), + Tables\Columns\TextColumn::make('created_at')->date()->sortable()->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('role')->options([ + 'admin' => 'Admin', 'manager' => 'Manager', 'receptionist' => 'Recepție', + 'mechanic' => 'Mecanic', 'parts_manager' => 'Magazie', 'accountant' => 'Contabil', 'marketer' => 'Marketing', + ]), + Tables\Filters\SelectFilter::make('status')->options([ + 'active' => 'Activ', 'inactive' => 'Inactiv', 'blocked' => 'Blocat', + ]), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListUsers::route('/'), + 'create' => Pages\CreateUser::route('/create'), + 'edit' => Pages\EditUser::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/UserResource/Pages/CreateUser.php b/app/Filament/Tenant/Resources/UserResource/Pages/CreateUser.php new file mode 100644 index 0000000..d4c85f2 --- /dev/null +++ b/app/Filament/Tenant/Resources/UserResource/Pages/CreateUser.php @@ -0,0 +1,11 @@ +count(); + $openDeals = Deal::whereNotIn('stage', ['done', 'lost'])->count(); + $todayAppointments = Appointment::whereDate('date', today())->count(); + + return [ + Stat::make('Clienți', Client::count()) + ->description('Total în baza de date') + ->icon('heroicon-o-users') + ->color('primary'), + + Stat::make('Mașini', Vehicle::count()) + ->description('Total înregistrate') + ->icon('heroicon-o-truck') + ->color('info'), + + Stat::make('Cereri noi', $newLeads) + ->description('De procesat') + ->icon('heroicon-o-inbox-arrow-down') + ->color($newLeads > 0 ? 'warning' : 'success'), + + Stat::make('Deal-uri active', $openDeals) + ->description('În pipeline') + ->icon('heroicon-o-funnel') + ->color('primary'), + + Stat::make('Programări azi', $todayAppointments) + ->description(today()->format('d.m.Y')) + ->icon('heroicon-o-calendar') + ->color('success'), + ]; + } +} diff --git a/app/Http/Middleware/ResolveTenant.php b/app/Http/Middleware/ResolveTenant.php index e918189..d386221 100644 --- a/app/Http/Middleware/ResolveTenant.php +++ b/app/Http/Middleware/ResolveTenant.php @@ -60,6 +60,12 @@ class ResolveTenant app(TenantManager::class)->setCurrent($company); $request->attributes->set('tenant', $company); + // Tell Spatie Permission to scope roles to this company. + if (function_exists('app')) { + app(\Spatie\Permission\PermissionRegistrar::class) + ->setPermissionsTeamId($company->id); + } + if ($required === 'required' && ! app(TenantManager::class)->isResolved()) { throw new NotFoundHttpException(); } diff --git a/app/Models/Tenant/Appointment.php b/app/Models/Tenant/Appointment.php new file mode 100644 index 0000000..d5054ff --- /dev/null +++ b/app/Models/Tenant/Appointment.php @@ -0,0 +1,55 @@ + 'Programat', + 'arrived' => 'Sosit', + 'done' => 'Finalizat', + 'cancelled' => 'Anulat', + 'no_show' => 'Neprezentat', + ]; + + protected $fillable = [ + 'company_id', 'post_id', 'client_id', 'vehicle_id', 'master_id', 'deal_id', + 'date', 'time_start', 'time_end', + 'title', 'color', 'status', 'notes', + ]; + + protected $casts = [ + 'date' => 'date', + ]; + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function master(): BelongsTo + { + return $this->belongsTo(User::class, 'master_id'); + } + + public function deal(): BelongsTo + { + return $this->belongsTo(Deal::class); + } +} diff --git a/app/Models/Tenant/Deal.php b/app/Models/Tenant/Deal.php new file mode 100644 index 0000000..d50fcf3 --- /dev/null +++ b/app/Models/Tenant/Deal.php @@ -0,0 +1,56 @@ + 'Nou', + 'contact' => 'Contact', + 'agree' => 'Aprobare', + 'scheduled' => 'Programat', + 'arrived' => 'Sosit', + 'in_work' => 'În lucru', + 'done' => 'Finalizat', + 'lost' => 'Pierdut', + ]; + + protected $fillable = [ + 'company_id', 'client_id', 'vehicle_id', + 'name', 'price', 'stage', 'source', 'note', + 'assigned_to', 'won_at', 'lost_at', 'lost_reason', + ]; + + protected $casts = [ + 'price' => 'decimal:2', + 'won_at' => 'datetime', + 'lost_at' => 'datetime', + ]; + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function isOpen(): bool + { + return ! in_array($this->stage, ['done', 'lost'], true); + } +} diff --git a/app/Models/Tenant/Lead.php b/app/Models/Tenant/Lead.php new file mode 100644 index 0000000..17f965f --- /dev/null +++ b/app/Models/Tenant/Lead.php @@ -0,0 +1,108 @@ + 'Nou', + 'contacted' => 'Contactat', + 'no_answer' => 'Fără răspuns', + 'scheduled' => 'Programat', + 'converted' => 'Convertit', + 'lost' => 'Pierdut', + ]; + + public const SOURCES = [ + 'manual' => 'Manual', + 'call' => 'Apel', + 'site' => 'Site', + 'telegram' => 'Telegram', + 'whatsapp' => 'WhatsApp', + 'viber' => 'Viber', + 'facebook' => 'Facebook', + 'instagram' => 'Instagram', + 'tiktok' => 'TikTok', + 'google' => 'Google', + 'google_maps' => 'Google Maps', + 'seo' => 'SEO', + 'recommend' => 'Recomandare', + ]; + + protected $fillable = [ + 'company_id', 'client_id', 'vehicle_id', + 'name', 'phone', 'email', 'car', 'model', 'message', + 'source', 'marketing_channel', + 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', + 'status', 'budget', 'assigned_to', 'deal_id', + 'contacted_at', 'converted_at', 'notes', + ]; + + protected $casts = [ + 'budget' => 'decimal:2', + 'contacted_at' => 'datetime', + 'converted_at' => 'datetime', + ]; + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function deal(): BelongsTo + { + return $this->belongsTo(Deal::class); + } + + /** Convert lead → client + deal (idempotent if already converted). */ + public function convert(?array $dealAttrs = null): Deal + { + if ($this->deal_id) { + return $this->deal; + } + + $client = $this->client_id + ? $this->client + : Client::firstOrCreate( + ['company_id' => $this->company_id, 'phone' => $this->phone], + ['type' => 'individual', 'name' => $this->name, 'email' => $this->email, 'source' => $this->source] + ); + + $deal = Deal::create(array_merge([ + 'company_id' => $this->company_id, + 'client_id' => $client->id, + 'name' => trim(($this->car ?? '') . ' ' . ($this->model ?? '')) ?: $this->name, + 'price' => $this->budget, + 'stage' => 'new', + 'source' => $this->source, + 'note' => $this->message, + 'assigned_to' => $this->assigned_to, + ], $dealAttrs ?? [])); + + $this->update([ + 'client_id' => $client->id, + 'deal_id' => $deal->id, + 'status' => 'converted', + 'converted_at' => now(), + ]); + + return $deal; + } +} diff --git a/app/Models/Tenant/Post.php b/app/Models/Tenant/Post.php new file mode 100644 index 0000000..4e1d20a --- /dev/null +++ b/app/Models/Tenant/Post.php @@ -0,0 +1,23 @@ + 'boolean', + ]; + + public function appointments(): HasMany + { + return $this->hasMany(Appointment::class); + } +} diff --git a/app/Models/Tenant/User.php b/app/Models/Tenant/User.php index 17d963d..8ef13f2 100644 --- a/app/Models/Tenant/User.php +++ b/app/Models/Tenant/User.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Spatie\Permission\Traits\HasRoles; /** * Tenant-bound user. Belongs to exactly one Company. @@ -17,7 +18,10 @@ use Illuminate\Notifications\Notifiable; */ class User extends Authenticatable implements FilamentUser { - use BelongsToTenant, HasFactory, Notifiable, SoftDeletes; + use BelongsToTenant, HasFactory, HasRoles, Notifiable, SoftDeletes; + + /** Spatie Permission scope key matches the team_foreign_key (company_id). */ + protected $guard_name = 'web'; protected $fillable = [ 'company_id', 'name', 'email', 'phone', 'avatar_url', diff --git a/app/Providers/Filament/CentralPanelProvider.php b/app/Providers/Filament/CentralPanelProvider.php index d4580a5..bf79247 100644 --- a/app/Providers/Filament/CentralPanelProvider.php +++ b/app/Providers/Filament/CentralPanelProvider.php @@ -43,6 +43,9 @@ class CentralPanelProvider extends PanelProvider Dashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Central/Widgets'), for: 'App\\Filament\\Central\\Widgets') + ->widgets([ + \App\Filament\Central\Widgets\PlatformStats::class, + ]) ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index 999eddc..8630427 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -42,6 +42,9 @@ class TenantPanelProvider extends PanelProvider Dashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Tenant/Widgets'), for: 'App\\Filament\\Tenant\\Widgets') + ->widgets([ + \App\Filament\Tenant\Widgets\StatsOverview::class, + ]) ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..493e80a --- /dev/null +++ b/config/permission.php @@ -0,0 +1,219 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + /* + * When using the "Teams" feature from this package, we need to know which + * Eloquent model should be used to retrieve your teams. Of course, it + * is often just the "Team" model but you may use whatever you like. + */ + 'team' => null, + + /* + * When using the "HasModels" trait and passing raw IDs to syncModels, + * attachModels, or detachModels, this model class will be used to + * resolve those IDs. If null, defaults to the guard's model. + */ + 'default_model' => null, + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'company_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => true, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/database/migrations/2026_05_06_164939_create_permission_tables.php b/database/migrations/2026_05_06_164939_create_permission_tables.php new file mode 100644 index 0000000..8986275 --- /dev/null +++ b/database/migrations/2026_05_06_164939_create_permission_tables.php @@ -0,0 +1,137 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/database/migrations/2026_05_06_170001_create_leads_table.php b/database/migrations/2026_05_06_170001_create_leads_table.php new file mode 100644 index 0000000..a84a158 --- /dev/null +++ b/database/migrations/2026_05_06_170001_create_leads_table.php @@ -0,0 +1,53 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('vehicle_id')->nullable()->constrained()->nullOnDelete(); + + $t->string('name'); + $t->string('phone'); + $t->string('email')->nullable(); + $t->string('car')->nullable(); + $t->string('model')->nullable(); + $t->text('message')->nullable(); + + $t->string('source')->nullable(); // call/site/telegram/whatsapp/instagram/google/... + $t->string('marketing_channel')->nullable(); + $t->string('utm_source')->nullable(); + $t->string('utm_medium')->nullable(); + $t->string('utm_campaign')->nullable(); + $t->string('utm_term')->nullable(); + $t->string('utm_content')->nullable(); + + $t->string('status')->default('new'); // new/contacted/no_answer/scheduled/converted/lost + $t->decimal('budget', 12, 2)->nullable(); + $t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $t->foreignId('deal_id')->nullable(); // set when converted + $t->timestamp('contacted_at')->nullable(); + $t->timestamp('converted_at')->nullable(); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'status']); + $t->index(['company_id', 'created_at']); + $t->index(['company_id', 'source']); + }); + } + + public function down(): void + { + Schema::dropIfExists('leads'); + } +}; diff --git a/database/migrations/2026_05_06_170002_create_deals_table.php b/database/migrations/2026_05_06_170002_create_deals_table.php new file mode 100644 index 0000000..049f80f --- /dev/null +++ b/database/migrations/2026_05_06_170002_create_deals_table.php @@ -0,0 +1,40 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('vehicle_id')->nullable()->constrained()->nullOnDelete(); + + $t->string('name'); // descriere scurtă: BMW X5 — Diagnostic + $t->decimal('price', 12, 2)->default(0); + $t->string('stage')->default('new'); // new/contact/agree/scheduled/arrived/in_work/done/lost + $t->string('source')->nullable(); + $t->text('note')->nullable(); + + $t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $t->timestamp('won_at')->nullable(); + $t->timestamp('lost_at')->nullable(); + $t->string('lost_reason')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'stage']); + $t->index(['company_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('deals'); + } +}; diff --git a/database/migrations/2026_05_06_170003_create_posts_and_appointments_tables.php b/database/migrations/2026_05_06_170003_create_posts_and_appointments_tables.php new file mode 100644 index 0000000..7e963a7 --- /dev/null +++ b/database/migrations/2026_05_06_170003_create_posts_and_appointments_tables.php @@ -0,0 +1,54 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('name'); // "Pod 1", "Pod 2" + $t->string('color', 16)->default('#3B82F6'); + $t->boolean('is_active')->default(true); + $t->unsignedSmallInteger('sort_order')->default(0); + $t->timestamps(); + + $t->index(['company_id', 'is_active', 'sort_order']); + }); + + Schema::create('appointments', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('post_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('vehicle_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('master_id')->nullable()->constrained('users')->nullOnDelete(); + $t->foreignId('deal_id')->nullable()->constrained()->nullOnDelete(); + + $t->date('date'); + $t->time('time_start'); + $t->time('time_end'); + $t->string('title'); + $t->string('color', 16)->nullable(); + $t->string('status')->default('scheduled'); // scheduled/arrived/done/cancelled/no_show + $t->text('notes')->nullable(); + + $t->timestamps(); + + $t->index(['company_id', 'date']); + $t->index(['company_id', 'post_id', 'date']); + $t->index(['company_id', 'master_id', 'date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('appointments'); + Schema::dropIfExists('posts'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 168f7f1..5662783 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,12 +5,18 @@ namespace Database\Seeders; use App\Models\Central\Company; use App\Models\Central\Plan; use App\Models\Central\SuperAdmin; +use App\Models\Tenant\Appointment; use App\Models\Tenant\Client; +use App\Models\Tenant\Deal; +use App\Models\Tenant\Lead; +use App\Models\Tenant\Post; use App\Models\Tenant\User; use App\Models\Tenant\Vehicle; use App\Tenancy\TenantManager; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; +use Spatie\Permission\Models\Role; +use Spatie\Permission\PermissionRegistrar; class DatabaseSeeder extends Seeder { @@ -70,9 +76,16 @@ class DatabaseSeeder extends Seeder // Activate tenant context for the seeded data so global scopes auto-fill company_id. app(TenantManager::class)->setCurrent($psauto); + app(PermissionRegistrar::class)->setPermissionsTeamId($psauto->id); + + // ─── Roles default per tenant ───────────────────────────── + $roleNames = ['admin', 'manager', 'receptionist', 'mechanic', 'parts_manager', 'accountant', 'marketer']; + foreach ($roleNames as $name) { + Role::findOrCreate($name, 'web'); + } // ─── Admin user pentru PSauto ───────────────────────────── - User::firstOrCreate( + $admin = User::firstOrCreate( ['company_id' => $psauto->id, 'email' => 'admin@psauto.md'], [ 'name' => 'Administrator PSauto', @@ -84,6 +97,7 @@ class DatabaseSeeder extends Seeder 'email_verified_at' => now(), ] ); + $admin->syncRoles(['admin']); // ─── Clienți demo (din AutoCRM.html) ────────────────────── $c1 = Client::firstOrCreate( @@ -129,11 +143,94 @@ class DatabaseSeeder extends Seeder ['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( + $v3 = 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'] ); + // ─── Posturi (boxe) ─────────────────────────────────────── + foreach ([['Pod 1', '#3B82F6'], ['Pod 2', '#E24B4A'], ['Pod 3', '#10B981']] as $i => [$name, $color]) { + Post::firstOrCreate( + ['company_id' => $psauto->id, 'name' => $name], + ['color' => $color, 'is_active' => true, 'sort_order' => $i + 1] + ); + } + + // ─── Lead-uri demo ──────────────────────────────────────── + Lead::firstOrCreate( + ['company_id' => $psauto->id, 'phone' => '+373 79 512 345'], + [ + 'name' => 'Alexandru Grosu', + 'car' => 'BMW', + 'model' => 'X5', + 'message' => 'Trebuie schimb lichid frână, roți față scrâșnesc', + 'source' => 'telegram', + 'status' => 'new', + ] + ); + Lead::firstOrCreate( + ['company_id' => $psauto->id, 'phone' => '+373 69 234 567'], + [ + 'name' => 'Irina Cojocaru', + 'email' => 'irina@mail.md', + 'car' => 'Audi', + 'model' => 'A4', + 'message' => 'Diagnosticare motor, lampa motor aprinsă', + 'source' => 'whatsapp', + 'status' => 'contacted', + 'budget' => 800, + ] + ); + + // ─── Deal-uri demo ──────────────────────────────────────── + Deal::firstOrCreate( + ['company_id' => $psauto->id, 'client_id' => $c1->id, 'name' => 'BMW X5 — Diagnostic'], + [ + 'vehicle_id' => $v1->id, 'price' => 800, + 'stage' => 'done', 'source' => 'call', 'note' => 'Diagnostică ISTA', + 'won_at' => now()->subDays(15), + ] + ); + Deal::firstOrCreate( + ['company_id' => $psauto->id, 'client_id' => $c2->id, 'name' => 'Audi A4 — Schimb ulei'], + [ + 'vehicle_id' => $v2->id, 'price' => 500, + 'stage' => 'in_work', 'source' => 'site', 'note' => 'Shell 5W-40', + ] + ); + Deal::firstOrCreate( + ['company_id' => $psauto->id, 'client_id' => $c3->id, 'name' => 'Porsche — Frâne Brembo'], + [ + 'vehicle_id' => $v3->id, 'price' => 2200, + 'stage' => 'agree', 'source' => 'instagram', 'note' => 'Așteaptă confirmare comanda Brembo', + ] + ); + + // ─── Programări demo (azi + 2 zile) ─────────────────────── + $post1 = Post::where('company_id', $psauto->id)->where('name', 'Pod 1')->first(); + $post2 = Post::where('company_id', $psauto->id)->where('name', 'Pod 2')->first(); + + Appointment::firstOrCreate( + ['company_id' => $psauto->id, 'client_id' => $c1->id, 'date' => today()->toDateString(), 'time_start' => '09:00:00'], + [ + 'post_id' => $post1?->id, 'vehicle_id' => $v1->id, 'master_id' => $admin->id, + 'time_end' => '11:00:00', + 'title' => 'BMW X5 — Diagnostic', + 'color' => '#3B82F6', + 'status' => 'scheduled', + ] + ); + Appointment::firstOrCreate( + ['company_id' => $psauto->id, 'client_id' => $c2->id, 'date' => today()->addDay()->toDateString(), 'time_start' => '10:00:00'], + [ + 'post_id' => $post2?->id, 'vehicle_id' => $v2->id, 'master_id' => $admin->id, + 'time_end' => '12:00:00', + 'title' => 'Audi A4 — Schimb ulei', + 'color' => '#E24B4A', + 'status' => 'scheduled', + ] + ); + app(TenantManager::class)->clear(); } } diff --git a/resources/views/filament/tenant/pages/settings.blade.php b/resources/views/filament/tenant/pages/settings.blade.php new file mode 100644 index 0000000..1763471 --- /dev/null +++ b/resources/views/filament/tenant/pages/settings.blade.php @@ -0,0 +1,11 @@ + +
+ {{ $this->form }} + +
+ + Salvează + +
+
+