diff --git a/app/Filament/Central/Resources/CompanyResource.php b/app/Filament/Central/Resources/CompanyResource.php index ab35edd..d6d4208 100644 --- a/app/Filament/Central/Resources/CompanyResource.php +++ b/app/Filament/Central/Resources/CompanyResource.php @@ -70,6 +70,23 @@ class CompanyResource extends Resource Forms\Components\DateTimePicker::make('trial_ends_at')->label('Trial expiră la'), Forms\Components\DateTimePicker::make('active_until')->label('Abonament până la'), ]), + Schemas\Components\Section::make('Admin tenant (la creare)') + ->columns(2) + ->visible(fn (string $operation) => $operation === 'create') + ->schema([ + Forms\Components\TextInput::make('admin_name') + ->label('Nume admin') + ->default('Administrator'), + Forms\Components\TextInput::make('admin_email') + ->label('Email admin') + ->email() + ->required(), + Forms\Components\TextInput::make('admin_password') + ->label('Parolă (lasă gol pentru auto-generat)') + ->password() + ->minLength(8) + ->helperText('Dacă e gol, generăm 10 caractere random.'), + ]), ]); } @@ -104,6 +121,20 @@ class CompanyResource extends Resource ]), ]) ->actions([ + Actions\Action::make('suspend') + ->label('Suspendă') + ->icon('heroicon-m-no-symbol') + ->color('danger') + ->visible(fn (Company $r) => in_array($r->status, ['active', 'trial'])) + ->requiresConfirmation() + ->action(fn (Company $r) => app(\App\Services\CompanyProvisioner::class)->suspend($r)), + Actions\Action::make('activate') + ->label('Activează') + ->icon('heroicon-m-check-circle') + ->color('success') + ->visible(fn (Company $r) => in_array($r->status, ['suspended', 'expired'])) + ->requiresConfirmation() + ->action(fn (Company $r) => app(\App\Services\CompanyProvisioner::class)->reactivate($r)), Actions\EditAction::make(), Actions\DeleteAction::make(), ]) diff --git a/app/Filament/Central/Resources/CompanyResource/Pages/CreateCompany.php b/app/Filament/Central/Resources/CompanyResource/Pages/CreateCompany.php index d361934..b1f33e6 100644 --- a/app/Filament/Central/Resources/CompanyResource/Pages/CreateCompany.php +++ b/app/Filament/Central/Resources/CompanyResource/Pages/CreateCompany.php @@ -3,9 +3,33 @@ namespace App\Filament\Central\Resources\CompanyResource\Pages; use App\Filament\Central\Resources\CompanyResource; +use App\Services\CompanyProvisioner; +use Filament\Notifications\Notification; use Filament\Resources\Pages\CreateRecord; class CreateCompany extends CreateRecord { protected static string $resource = CompanyResource::class; + + /** Override the standard create flow: delegate to CompanyProvisioner. */ + protected function handleRecordCreation(array $data): \Illuminate\Database\Eloquent\Model + { + $result = app(CompanyProvisioner::class)->provision($data); + + $msg = "Admin: {$result['admin_email']}\nParolă: {$result['admin_password']}"; + if ($result['deploy_triggered']) { + $msg .= "\nSubdomain adăugat în Coolify, redeploy declanșat (~90s)."; + } else { + $msg .= "\n⚠️ Coolify nu e configurat — adaugă manual https://{$result['company']->slug}.service.mir.md:8000 la FQDN-ul aplicației."; + } + + Notification::make() + ->title("Companie creată: {$result['company']->name}") + ->body($msg) + ->persistent() + ->success() + ->send(); + + return $result['company']; + } } diff --git a/app/Filament/Tenant/Pages/Reports.php b/app/Filament/Tenant/Pages/Reports.php new file mode 100644 index 0000000..78effa3 --- /dev/null +++ b/app/Filament/Tenant/Pages/Reports.php @@ -0,0 +1,220 @@ +period) { + 'today' => [Carbon::today(), Carbon::today()->endOfDay()], + 'this_week' => [Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek()], + 'this_month' => [Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth()], + 'last_month' => [Carbon::now()->subMonthNoOverflow()->startOfMonth(), Carbon::now()->subMonthNoOverflow()->endOfMonth()], + 'this_year' => [Carbon::now()->startOfYear(), Carbon::now()->endOfYear()], + default => [Carbon::now()->subYear(), Carbon::now()], + }; + } + + public function periods(): array + { + return [ + 'today' => 'Astăzi', + 'this_week' => 'Săptămâna curentă', + 'this_month' => 'Luna curentă', + 'last_month' => 'Luna trecută', + 'this_year' => 'Anul curent', + ]; + } + + public function tabs(): array + { + return [ + 'finance' => '💰 Finanțe', + 'workload' => '📊 Încărcare', + 'masters' => '👨‍🔧 Mecanici', + 'works' => '🔧 Manopere top', + 'parts' => '📦 Piese', + 'clients' => '👥 Clienți', + ]; + } + + public function setPeriod(string $period): void + { + $this->period = $period; + } + + public function setTab(string $tab): void + { + $this->tab = $tab; + } + + public function data(): array + { + [$start, $end] = $this->dateRange(); + + return match ($this->tab) { + 'finance' => $this->financeReport($start, $end), + 'workload' => $this->workloadReport($start, $end), + 'masters' => $this->mastersReport($start, $end), + 'works' => $this->popularWorksReport($start, $end), + 'parts' => $this->partsReport($start, $end), + 'clients' => $this->clientsReport($start, $end), + default => [], + }; + } + + protected function financeReport($start, $end): array + { + $income = (float) Payment::whereBetween('paid_at', [$start, $end])->sum('amount'); + $expenses = (float) Expense::whereBetween('paid_at', [$start, $end])->sum('amount'); + + $byMethod = Payment::whereBetween('paid_at', [$start, $end]) + ->selectRaw('method, COUNT(*) as cnt, SUM(amount) as total') + ->groupBy('method')->get(); + + $byCategory = Expense::whereBetween('paid_at', [$start, $end]) + ->selectRaw('category, COUNT(*) as cnt, SUM(amount) as total') + ->groupBy('category')->orderByDesc('total')->get(); + + $debt = (float) WorkOrder::where('pay_status', '!=', 'paid') + ->whereNotIn('status', ['cancelled']) + ->get() + ->sum(fn ($w) => $w->balanceDue()); + + return [ + 'income' => $income, + 'expenses' => $expenses, + 'profit' => $income - $expenses, + 'margin_pct' => $income > 0 ? round((($income - $expenses) / $income) * 100, 1) : 0, + 'by_method' => $byMethod, + 'by_category' => $byCategory, + 'debt' => $debt, + ]; + } + + protected function workloadReport($start, $end): array + { + $opened = WorkOrder::whereBetween('opened_at', [$start, $end])->count(); + $closed = WorkOrder::whereBetween('closed_at', [$start, $end])->count(); + + $byStatus = WorkOrder::selectRaw('status, COUNT(*) as cnt') + ->whereBetween('opened_at', [$start, $end]) + ->groupBy('status')->get(); + + $byDay = WorkOrder::selectRaw('DATE(opened_at) as day, COUNT(*) as cnt') + ->whereBetween('opened_at', [$start, $end]) + ->groupBy('day')->orderBy('day')->get(); + + return [ + 'opened' => $opened, + 'closed' => $closed, + 'by_status' => $byStatus, + 'by_day' => $byDay, + ]; + } + + protected function mastersReport($start, $end): array + { + $rows = User::where('role', 'mechanic')->get()->map(function ($u) use ($start, $end) { + $works = WorkOrderWork::where('master_id', $u->id) + ->whereHas('workOrder', fn ($q) => $q->whereBetween('opened_at', [$start, $end])) + ->get(); + $hoursTotal = (float) $works->sum('hours'); + $revenueTotal = (float) $works->sum('total'); + $worksCount = $works->count(); + return [ + 'id' => $u->id, + 'name' => $u->name, + 'specialization' => $u->specialization, + 'hours' => $hoursTotal, + 'works' => $worksCount, + 'revenue' => $revenueTotal, + ]; + })->sortByDesc('revenue')->values(); + + return ['rows' => $rows]; + } + + protected function popularWorksReport($start, $end): array + { + $rows = WorkOrderWork::selectRaw('name, COUNT(*) as cnt, SUM(hours) as hours, SUM(total) as revenue') + ->whereHas('workOrder', fn ($q) => $q->whereBetween('opened_at', [$start, $end])) + ->groupBy('name') + ->orderByDesc('cnt') + ->limit(20) + ->get(); + + return ['rows' => $rows]; + } + + protected function partsReport($start, $end): array + { + $sold = WorkOrderPart::selectRaw('name, brand, SUM(qty) as qty, SUM(total) as revenue, SUM((sell_price - buy_price) * qty) as margin') + ->whereHas('workOrder', fn ($q) => $q->whereBetween('opened_at', [$start, $end])) + ->where('status', 'installed') + ->groupBy('name', 'brand') + ->orderByDesc('revenue') + ->limit(20) + ->get(); + + $low = Part::where('is_active', true) + ->whereColumn('qty', '<=', 'min_qty') + ->orderBy('qty') + ->get(); + + return ['sold' => $sold, 'low' => $low]; + } + + protected function clientsReport($start, $end): array + { + $top = Client::withCount(['vehicles']) + ->withSum(['workOrders' => fn ($q) => $q->whereBetween('opened_at', [$start, $end])], 'total') + ->orderByDesc('work_orders_sum_total') + ->limit(20) + ->get(); + + // Fallback: if relation doesn't exist on Client + if ($top->isEmpty() || ! $top->first()->relationLoaded('workOrders')) { + $top = Client::withCount('vehicles')->limit(20)->get(); + } + + $newCount = Client::whereBetween('created_at', [$start, $end])->count(); + + $bySource = Lead::selectRaw('source, COUNT(*) as cnt') + ->whereBetween('created_at', [$start, $end]) + ->groupBy('source') + ->orderByDesc('cnt') + ->get(); + + return ['top' => $top, 'new_count' => $newCount, 'by_source' => $bySource]; + } +} diff --git a/app/Filament/Tenant/Resources/CallResource.php b/app/Filament/Tenant/Resources/CallResource.php new file mode 100644 index 0000000..8998b51 --- /dev/null +++ b/app/Filament/Tenant/Resources/CallResource.php @@ -0,0 +1,102 @@ +components([ + Schemas\Components\Section::make('Apel') + ->columns(2) + ->schema([ + Forms\Components\DateTimePicker::make('called_at')->label('Data & ora')->default(now())->required(), + Forms\Components\Select::make('direction') + ->options(Call::DIRECTIONS) + ->default('incoming') + ->required(), + Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->required()->maxLength(40), + Forms\Components\Select::make('status') + ->options(Call::STATUSES) + ->default('answered') + ->required(), + Forms\Components\TextInput::make('duration_sec')->label('Durată (sec)')->numeric()->default(0), + Forms\Components\Select::make('client_id') + ->label('Client') + ->options(fn () => Client::pluck('name', 'id')) + ->searchable(), + ]), + 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('called_at')->label('Data')->dateTime('d.m.Y H:i')->sortable(), + Tables\Columns\TextColumn::make('direction') + ->formatStateUsing(fn ($s) => Call::DIRECTIONS[$s] ?? $s) + ->badge() + ->colors([ + 'success' => ['incoming'], + 'info' => ['outgoing'], + 'danger' => ['missed'], + ]), + Tables\Columns\TextColumn::make('phone')->copyable()->searchable(), + Tables\Columns\TextColumn::make('client.name')->label('Client')->placeholder('—'), + Tables\Columns\TextColumn::make('duration_formatted')->label('Durată')->state(fn (Call $r) => $r->duration_formatted), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($s) => Call::STATUSES[$s] ?? $s) + ->badge(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('direction')->options(Call::DIRECTIONS), + Tables\Filters\Filter::make('today') + ->label('Astăzi') + ->query(fn ($q) => $q->whereDate('called_at', today())), + Tables\Filters\Filter::make('missed') + ->label('Pierdute') + ->query(fn ($q) => $q->where('direction', 'missed')->orWhere('status', 'missed')), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('called_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCalls::route('/'), + 'create' => Pages\CreateCall::route('/create'), + 'edit' => Pages\EditCall::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/CallResource/Pages/CreateCall.php b/app/Filament/Tenant/Resources/CallResource/Pages/CreateCall.php new file mode 100644 index 0000000..f631677 --- /dev/null +++ b/app/Filament/Tenant/Resources/CallResource/Pages/CreateCall.php @@ -0,0 +1,14 @@ +components([ + Schemas\Components\Section::make('Identificare') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('name')->label('Nume canal')->required()->maxLength(120), + Forms\Components\TextInput::make('icon')->label('Iconiță (emoji)')->maxLength(8)->placeholder('🔍 / 📘 / 📸'), + Forms\Components\ColorPicker::make('color'), + ]), + Schemas\Components\Section::make('Buget & rezultate (luna curentă)') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('budget_monthly')->label('Buget')->numeric()->default(0), + Forms\Components\TextInput::make('spent_monthly')->label('Cheltuit')->numeric()->default(0), + Forms\Components\TextInput::make('revenue')->label('Venit generat')->numeric()->default(0), + Forms\Components\TextInput::make('leads_count')->label('Lead-uri')->numeric()->default(0), + Forms\Components\TextInput::make('converted_count')->label('Convertite')->numeric()->default(0), + Forms\Components\Toggle::make('is_active')->label('Activ')->default(true), + ]), + Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\ColorColumn::make('color')->label(''), + Tables\Columns\TextColumn::make('icon')->label('')->width(40), + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('budget_monthly')->label('Buget')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('spent_monthly')->label('Cheltuit')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('leads_count')->label('Lead-uri')->alignRight(), + Tables\Columns\TextColumn::make('converted_count')->label('Convertite')->alignRight(), + Tables\Columns\TextColumn::make('revenue')->label('Venit')->money('MDL')->alignRight()->color('success'), + Tables\Columns\TextColumn::make('roi') + ->label('ROI') + ->state(fn (MarketingChannel $r) => $r->roi) + ->formatStateUsing(fn ($s) => $s . '%') + ->color(fn ($s) => $s >= 0 ? 'success' : 'danger') + ->alignRight(), + Tables\Columns\TextColumn::make('cost_per_lead') + ->label('Cost/lead') + ->state(fn (MarketingChannel $r) => $r->cost_per_lead) + ->money('MDL') + ->alignRight(), + Tables\Columns\IconColumn::make('is_active')->boolean(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_active')->label('Active'), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('revenue', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListMarketingChannels::route('/'), + 'create' => Pages\CreateMarketingChannel::route('/create'), + 'edit' => Pages\EditMarketingChannel::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/MarketingChannelResource/Pages/CreateMarketingChannel.php b/app/Filament/Tenant/Resources/MarketingChannelResource/Pages/CreateMarketingChannel.php new file mode 100644 index 0000000..2f612ad --- /dev/null +++ b/app/Filament/Tenant/Resources/MarketingChannelResource/Pages/CreateMarketingChannel.php @@ -0,0 +1,14 @@ +components([ + Schemas\Components\Section::make('Identificare') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('name')->label('Nume template')->required()->maxLength(120), + Forms\Components\Select::make('channel') + ->options(MessageTemplate::CHANNELS) + ->default('telegram') + ->required(), + Forms\Components\TextInput::make('subject')->label('Subiect (email)')->maxLength(160)->columnSpanFull(), + Forms\Components\Toggle::make('is_active')->label('Activ')->default(true), + ]), + Schemas\Components\Section::make('Conținut') + ->columns(1) + ->schema([ + Forms\Components\Textarea::make('body') + ->label('Mesaj') + ->required() + ->rows(6) + ->helperText('Variabile disponibile: {name}, {car}, {date}, {time}, {amount}, {service}, {mileage}'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('channel') + ->formatStateUsing(fn ($s) => MessageTemplate::CHANNELS[$s] ?? $s) + ->badge(), + Tables\Columns\TextColumn::make('body')->label('Preview')->limit(60), + Tables\Columns\IconColumn::make('is_active')->boolean(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('channel')->options(MessageTemplate::CHANNELS), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('channel'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListMessageTemplates::route('/'), + 'create' => Pages\CreateMessageTemplate::route('/create'), + 'edit' => Pages\EditMessageTemplate::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/MessageTemplateResource/Pages/CreateMessageTemplate.php b/app/Filament/Tenant/Resources/MessageTemplateResource/Pages/CreateMessageTemplate.php new file mode 100644 index 0000000..67cb452 --- /dev/null +++ b/app/Filament/Tenant/Resources/MessageTemplateResource/Pages/CreateMessageTemplate.php @@ -0,0 +1,14 @@ + 'Primit', + 'outgoing' => 'Efectuat', + 'missed' => 'Pierdut', + ]; + + public const STATUSES = [ + 'answered' => 'Răspuns', + 'missed' => 'Pierdut', + 'busy' => 'Ocupat', + 'no_answer' => 'Fără răspuns', + ]; + + protected $fillable = [ + 'company_id', 'client_id', 'lead_id', 'user_id', + 'direction', 'phone', 'called_at', 'duration_sec', + 'status', 'recording_url', 'notes', 'lead_created', + ]; + + protected $casts = [ + 'called_at' => 'datetime', + 'lead_created' => 'boolean', + ]; + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function lead(): BelongsTo + { + return $this->belongsTo(Lead::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function getDurationFormattedAttribute(): string + { + $sec = (int) $this->duration_sec; + if ($sec === 0) return '—'; + return sprintf('%d:%02d', intdiv($sec, 60), $sec % 60); + } +} diff --git a/app/Models/Tenant/Client.php b/app/Models/Tenant/Client.php index c91849c..f5639ce 100644 --- a/app/Models/Tenant/Client.php +++ b/app/Models/Tenant/Client.php @@ -32,6 +32,16 @@ class Client extends Model return $this->hasMany(Vehicle::class); } + public function workOrders(): HasMany + { + return $this->hasMany(WorkOrder::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + public function assignedTo(): BelongsTo { return $this->belongsTo(User::class, 'assigned_to'); diff --git a/app/Models/Tenant/MarketingChannel.php b/app/Models/Tenant/MarketingChannel.php new file mode 100644 index 0000000..6862f64 --- /dev/null +++ b/app/Models/Tenant/MarketingChannel.php @@ -0,0 +1,53 @@ + 'decimal:2', + 'spent_monthly' => 'decimal:2', + 'revenue' => 'decimal:2', + 'is_active' => 'boolean', + ]; + + public function getRoiAttribute(): float + { + $spent = (float) $this->spent_monthly; + if ($spent <= 0) { + return 0; + } + return round((((float) $this->revenue - $spent) / $spent) * 100, 2); + } + + public function getConversionRateAttribute(): float + { + $leads = (int) $this->leads_count; + if ($leads <= 0) { + return 0; + } + return round(((int) $this->converted_count / $leads) * 100, 2); + } + + public function getCostPerLeadAttribute(): float + { + $leads = (int) $this->leads_count; + if ($leads <= 0) { + return 0; + } + return round((float) $this->spent_monthly / $leads, 2); + } +} diff --git a/app/Models/Tenant/MessageTemplate.php b/app/Models/Tenant/MessageTemplate.php new file mode 100644 index 0000000..a0a1421 --- /dev/null +++ b/app/Models/Tenant/MessageTemplate.php @@ -0,0 +1,44 @@ + 'Telegram', + 'whatsapp' => 'WhatsApp', + 'viber' => 'Viber', + 'sms' => 'SMS', + 'email' => 'Email', + ]; + + protected $fillable = [ + 'company_id', 'name', 'channel', 'subject', + 'body', 'variables', 'is_active', + ]; + + protected $casts = [ + 'variables' => 'array', + 'is_active' => 'boolean', + ]; + + /** + * Render template body with the given context, replacing {key} tokens. + */ + public function render(array $context = []): string + { + $body = $this->body; + foreach ($context as $key => $val) { + $body = str_replace('{' . $key . '}', (string) $val, $body); + } + return $body; + } +} diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index 723f66e..38327f7 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -12,6 +12,8 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; +use Filament\View\PanelsRenderHook; +use Illuminate\Support\Facades\Blade; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; @@ -68,6 +70,34 @@ class TenantPanelProvider extends PanelProvider ]) ->authMiddleware([ Authenticate::class, - ]); + ]) + // PWA + theming injection + ->renderHook( + PanelsRenderHook::HEAD_END, + fn (): string => Blade::render(<<<'BLADE' + @php + $t = app(\App\Tenancy\TenantManager::class)->current(); + $themeColor = $t?->settings['theme_color'] ?? '#3B82F6'; + $name = $t?->display_name ?? $t?->name ?? 'AutoCRM'; + @endphp + + + + + + BLADE) + ) + ->renderHook( + PanelsRenderHook::BODY_END, + fn (): string => <<<'HTML' + + HTML + ); } } diff --git a/app/Services/CompanyProvisioner.php b/app/Services/CompanyProvisioner.php new file mode 100644 index 0000000..bff0966 --- /dev/null +++ b/app/Services/CompanyProvisioner.php @@ -0,0 +1,119 @@ + Plan::where('slug', 'free')->value('id'), + 'status' => 'trial', + 'trial_ends_at' => now()->addDays(14), + 'settings' => [ + 'currency' => 'MDL', + 'language' => 'ro', + 'theme_color' => '#3B82F6', + 'labor_rate' => 400, + ], + ]; + + return DB::transaction(function () use ($data, $defaults) { + $company = Company::create(array_merge($defaults, [ + 'slug' => $data['slug'], + 'name' => $data['name'], + 'display_name' => $data['display_name'] ?? $data['name'], + 'city' => $data['city'] ?? null, + 'phone' => $data['phone'] ?? null, + 'email' => $data['email'] ?? null, + 'contact_name' => $data['contact_name'] ?? null, + ])); + + // Activate tenant context to seed roles + user with company_id auto-fill. + $this->tenants->setCurrent($company); + $this->permissions->setPermissionsTeamId($company->id); + + // Default roles per tenant. + foreach (['admin', 'manager', 'receptionist', 'mechanic', 'parts_manager', 'accountant', 'marketer'] as $r) { + Role::findOrCreate($r, 'web'); + } + + // Admin user. + $adminEmail = $data['admin_email'] ?? "admin@{$company->slug}.local"; + $plainPassword = $data['admin_password'] ?? Str::password(10, true, true, false); + $admin = User::create([ + 'company_id' => $company->id, + 'name' => $data['admin_name'] ?? 'Administrator', + 'email' => $adminEmail, + 'password' => Hash::make($plainPassword), + 'role' => 'admin', + 'status' => 'active', + 'locale' => 'ro', + 'email_verified_at' => now(), + ]); + $admin->syncRoles(['admin']); + + $this->tenants->clear(); + + // Add subdomain to Coolify FQDN list + trigger redeploy. + $deployTriggered = false; + if ($this->coolify->isConfigured() && env('COOLIFY_APP_UUID')) { + $appUuid = (string) env('COOLIFY_APP_UUID'); + $url = $company->url(''); + $url = rtrim($url, '/') . ':8000'; // internal port suffix Coolify expects + if ($this->coolify->addDomain($appUuid, $url)) { + $deployTriggered = $this->coolify->deploy($appUuid, true); + } + } + + return [ + 'company' => $company->fresh(), + 'admin_email' => $adminEmail, + 'admin_password' => $plainPassword, + 'deploy_triggered' => $deployTriggered, + ]; + }); + } + + public function suspend(Company $company): void + { + $company->update(['status' => 'suspended']); + } + + public function reactivate(Company $company): void + { + $company->update(['status' => 'active']); + } + + public function archive(Company $company): void + { + $company->update(['status' => 'archived']); + $company->delete(); // soft-delete + } +} diff --git a/app/Services/CoolifyClient.php b/app/Services/CoolifyClient.php new file mode 100644 index 0000000..a9c5153 --- /dev/null +++ b/app/Services/CoolifyClient.php @@ -0,0 +1,105 @@ + + * COOLIFY_APP_UUID= + */ +class CoolifyClient +{ + public function __construct( + protected ?string $base = null, + protected ?string $token = null, + ) { + $this->base = rtrim($base ?? (string) env('COOLIFY_API_URL'), '/'); + $this->token = $token ?? (string) env('COOLIFY_API_TOKEN'); + } + + public function isConfigured(): bool + { + return $this->base !== '' && $this->token !== ''; + } + + protected function http() + { + return Http::withToken($this->token) + ->withHeaders(['Accept' => 'application/json']) + ->withOptions(['verify' => false]) + ->timeout(15); + } + + public function getApp(string $uuid): ?array + { + if (! $this->isConfigured()) return null; + $r = $this->http()->get($this->base . '/api/v1/applications/' . $uuid); + return $r->ok() ? $r->json() : null; + } + + /** + * Add a new domain to the application's FQDN list (idempotent). + * Returns true if successful or already present. + */ + public function addDomain(string $appUuid, string $url): bool + { + if (! $this->isConfigured()) { + Log::warning('CoolifyClient not configured; skipping addDomain', ['url' => $url]); + return false; + } + + $app = $this->getApp($appUuid); + if (! $app) { + Log::error('CoolifyClient: cannot fetch app', ['uuid' => $appUuid]); + return false; + } + + $current = (string) ($app['fqdn'] ?? ''); + $domains = array_filter(array_map('trim', explode(',', $current))); + if (in_array($url, $domains, true)) { + return true; + } + $domains[] = $url; + $newFqdn = implode(',', $domains); + + $r = $this->http()->patch( + $this->base . '/api/v1/applications/' . $appUuid, + ['domains' => $newFqdn] + ); + + if (! $r->successful()) { + Log::error('CoolifyClient addDomain failed', [ + 'status' => $r->status(), + 'body' => $r->body(), + ]); + return false; + } + + return true; + } + + /** + * Trigger a redeploy on the app (after FQDN change Coolify needs redeploy + * to update Traefik labels). + */ + public function deploy(string $appUuid, bool $force = true): bool + { + if (! $this->isConfigured()) return false; + $r = $this->http()->post($this->base . '/api/v1/deploy', [ + 'uuid' => $appUuid, + 'force' => $force, + ]); + if (! $r->successful()) { + // try query string variant (Coolify's GET /deploy?uuid=...) + $r = $this->http()->post($this->base . '/api/v1/deploy?uuid=' . $appUuid . '&force=' . ($force ? 'true' : 'false')); + } + return $r->successful(); + } +} diff --git a/database/migrations/2026_05_06_210001_create_marketing_tables.php b/database/migrations/2026_05_06_210001_create_marketing_tables.php new file mode 100644 index 0000000..0db2d40 --- /dev/null +++ b/database/migrations/2026_05_06_210001_create_marketing_tables.php @@ -0,0 +1,76 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('name'); + $t->string('channel'); // telegram / whatsapp / sms / email / viber + $t->string('subject')->nullable(); + $t->text('body'); // poate avea {name}, {car}, {date} ... + $t->json('variables')->nullable(); // [['key'=>'name','label'=>'Nume client']] + $t->boolean('is_active')->default(true); + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'channel']); + $t->index(['company_id', 'is_active']); + }); + + Schema::create('marketing_channels', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('name'); // Google Ads, FB, IG, Telegram, ... + $t->string('icon', 16)->nullable(); // emoji + $t->string('color', 16)->nullable(); + $t->decimal('budget_monthly', 12, 2)->default(0); + $t->decimal('spent_monthly', 12, 2)->default(0); + $t->unsignedInteger('leads_count')->default(0); + $t->unsignedInteger('converted_count')->default(0); + $t->decimal('revenue', 12, 2)->default(0); + $t->boolean('is_active')->default(true); + $t->text('notes')->nullable(); + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'is_active']); + }); + + Schema::create('calls', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('lead_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + + $t->string('direction'); // incoming / outgoing / missed + $t->string('phone'); + $t->timestamp('called_at'); + $t->unsignedInteger('duration_sec')->default(0); + $t->string('status')->default('answered'); // answered / missed / busy / no_answer + $t->string('recording_url')->nullable(); + $t->text('notes')->nullable(); + $t->boolean('lead_created')->default(false); + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'called_at']); + $t->index(['company_id', 'direction']); + $t->index(['company_id', 'phone']); + }); + } + + public function down(): void + { + Schema::dropIfExists('calls'); + Schema::dropIfExists('marketing_channels'); + Schema::dropIfExists('msg_templates'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9f61e57..9673a2e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,7 +13,10 @@ use App\Models\Tenant\Lead; use App\Models\Tenant\Post; use App\Models\Tenant\User; use App\Models\Tenant\Vehicle; +use App\Models\Tenant\Call; use App\Models\Tenant\Expense; +use App\Models\Tenant\MarketingChannel; +use App\Models\Tenant\MessageTemplate; use App\Models\Tenant\Part; use App\Models\Tenant\Payment; use App\Models\Tenant\Purchase; @@ -424,6 +427,52 @@ class DatabaseSeeder extends Seeder ['client_id' => $c1->id, 'method' => 'cash', 'reference' => 'CHIT-001', 'user_id' => $admin->id] ); + // ─── Template-uri mesaje demo ─────────────────────────── + $templates = [ + ['Programare confirmată', 'telegram', 'Salut, {name}! Programarea pentru {service} este confirmată pe {date} la {time}. Vă așteptăm! 🚗'], + ['Auto gata de ridicare', 'telegram', 'Salut, {name}! Mașina dvs. {car} este gata. Total: {amount} MDL. ✅'], + ['Reminder revizie', 'whatsapp', 'Salut, {name}! Vă reamintim — pentru {car} se apropie revizia (kilometraj {mileage}). Programați-vă din timp! 🔧'], + ['Reminder ITP', 'sms', '{name}, ITP-ul mașinii {car} expiră luna aceasta. Programați-vă: 022-123-456'], + ['Felicitare zi naștere', 'telegram', '🎉 La mulți ani, {name}! Discount 15% la orice manoperă luna aceasta.'], + ]; + foreach ($templates as [$name, $channel, $body]) { + MessageTemplate::firstOrCreate( + ['company_id' => $psauto->id, 'name' => $name], + ['channel' => $channel, 'body' => $body, 'is_active' => true] + ); + } + + // ─── Canale marketing demo ────────────────────────────── + $channels = [ + ['Google Ads', '🔍', '#EA4335', 5000, 4200, 28, 12, 48000], + ['Facebook', '📘', '#1877F2', 3000, 2800, 18, 7, 22000], + ['Instagram', '📸', '#C13584', 2000, 1900, 22, 9, 31000], + ['Telegram', '✈️', '#229ED9', 500, 200, 15, 8, 26000], + ['Recomandări', '⭐', '#F59E0B', 0, 0, 35, 25, 95000], + ]; + foreach ($channels as [$name, $icon, $color, $budget, $spent, $leads, $conv, $revenue]) { + MarketingChannel::firstOrCreate( + ['company_id' => $psauto->id, 'name' => $name], + [ + 'icon' => $icon, 'color' => $color, + 'budget_monthly' => $budget, 'spent_monthly' => $spent, + 'leads_count' => $leads, 'converted_count' => $conv, + 'revenue' => $revenue, 'is_active' => true, + ] + ); + } + + // ─── Apeluri demo ─────────────────────────────────────── + Call::firstOrCreate( + ['company_id' => $psauto->id, 'phone' => '+373 69 100001', 'called_at' => today()->subHours(3)], + ['direction' => 'incoming', 'duration_sec' => 185, 'status' => 'answered', + 'client_id' => $c1->id, 'user_id' => $admin->id, 'notes' => 'Programare diagnostic'] + ); + Call::firstOrCreate( + ['company_id' => $psauto->id, 'phone' => '+373 79 602002', 'called_at' => today()->subHours(5)], + ['direction' => 'missed', 'duration_sec' => 0, 'status' => 'missed', 'notes' => ''] + ); + // ─── Cheltuieli demo ──────────────────────────────────── $expensesData = [ ['salary', 'Salariu Vasile Ivanov', 8000, today()->startOfMonth(), 'cash'], diff --git a/resources/views/filament/tenant/pages/reports.blade.php b/resources/views/filament/tenant/pages/reports.blade.php new file mode 100644 index 0000000..dcbea4a --- /dev/null +++ b/resources/views/filament/tenant/pages/reports.blade.php @@ -0,0 +1,224 @@ + + @php + $data = $this->data(); + $tabs = $this->tabs(); + $periods = $this->periods(); + @endphp + +
+ {{-- Period selector --}} +
+ Perioadă: + @foreach ($periods as $key => $label) + + @endforeach +
+ + {{-- Tabs --}} +
+ @foreach ($tabs as $key => $label) + + @endforeach +
+ + {{-- Content per tab --}} + @if ($tab === 'finance') +
+ @foreach ([ + ['Încasări', $data['income'], 'success'], + ['Cheltuieli', $data['expenses'], 'danger'], + ['Profit', $data['profit'], $data['profit'] >= 0 ? 'success' : 'danger'], + ['Datorii clienți', $data['debt'], 'warning'], + ] as [$label, $value, $color]) +
+
{{ $label }}
+
+ {{ number_format((float)$value, 2, '.', ' ') }} MDL +
+
+ @endforeach +
+
+
+

Încasări pe metodă

+ + + + @forelse ($data['by_method'] as $row) + + + + + + @empty + + @endforelse + +
MetodăTranz.Total
{{ \App\Models\Tenant\Payment::METHODS[$row->method] ?? $row->method }}{{ $row->cnt }}{{ number_format((float)$row->total, 2, '.', ' ') }}
Nicio plată în perioada selectată.
+
+
+

Cheltuieli pe categorie

+ + + + @forelse ($data['by_category'] as $row) + + + + + + @empty + + @endforelse + +
CategorieNr.Total
{{ \App\Models\Tenant\Expense::CATEGORIES[$row->category] ?? $row->category }}{{ $row->cnt }}{{ number_format((float)$row->total, 2, '.', ' ') }}
Nicio cheltuială.
+
+
+
Marjă profit: {{ $data['margin_pct'] }}%
+ @elseif ($tab === 'workload') +
+
+
Fișe deschise
+
{{ $data['opened'] }}
+
+
+
Fișe închise
+
{{ $data['closed'] }}
+
+
+
+

Pe status

+ + + @foreach ($data['by_status'] as $row) + + + + + @endforeach + +
{{ \App\Models\Tenant\WorkOrder::STATUSES[$row->status] ?? $row->status }}{{ $row->cnt }}
+
+ @elseif ($tab === 'masters') +
+ + + + + + + + + + @forelse ($data['rows'] as $r) + + + + + + + + @empty + + @endforelse + +
MecanicSpecializareManopereOreVenit
{{ $r['name'] }}{{ $r['specialization'] ?? '—' }}{{ $r['works'] }}{{ number_format($r['hours'], 1) }}{{ number_format($r['revenue'], 2, '.', ' ') }}
Niciun mecanic activ.
+
+ @elseif ($tab === 'works') +
+ + + + + + + + + @forelse ($data['rows'] as $r) + + + + + + + @empty + + @endforelse + +
ManoperăNr.Ore totalVenit
{{ $r->name }}{{ $r->cnt }}{{ number_format((float)$r->hours, 1) }}{{ number_format((float)$r->revenue, 2, '.', ' ') }}
Nicio manoperă efectuată.
+
+ @elseif ($tab === 'parts') +
+
+

Top piese vândute

+ + + + @forelse ($data['sold'] as $r) + + + + + + + @empty + + @endforelse + +
PiesăCant.VenitMarjă
{{ $r->name }} {{ $r->brand }}{{ number_format((float)$r->qty, 2) }}{{ number_format((float)$r->revenue, 2, '.', ' ') }}{{ number_format((float)$r->margin, 2, '.', ' ') }}
Nicio piesă montată.
+
+
+

⚠️ Stoc minim atins

+ + + + @forelse ($data['low'] as $p) + + + + + + @empty + + @endforelse + +
PiesăStocMin.
{{ $p->name }}{{ $p->qty }}{{ $p->min_qty }}
Toate piesele sunt în stoc.
+
+
+ @elseif ($tab === 'clients') +
+
+
Clienți noi în perioada
+
{{ $data['new_count'] }}
+
+
+

Lead-uri pe sursă

+ + @foreach ($data['by_source'] as $row) + + + + + @endforeach +
{{ \App\Models\Tenant\Lead::SOURCES[$row->source] ?? ($row->source ?? '—') }}{{ $row->cnt }}
+
+
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index 86a06c5..6847bbe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,62 @@ current(); + $name = $tenant?->display_name ?? $tenant?->name ?? 'AutoCRM'; + $themeColor = $tenant?->settings['theme_color'] ?? '#3B82F6'; + $shortName = $tenant?->slug ?? 'autocrm'; + + return response()->json([ + 'name' => $name, + 'short_name' => mb_substr($shortName, 0, 12), + 'description' => 'CRM autoservice — ' . $name, + 'start_url' => '/app', + 'display' => 'standalone', + 'orientation' => 'any', + 'background_color' => '#ffffff', + 'theme_color' => $themeColor, + 'lang' => $tenant?->settings['language'] ?? 'ro', + 'icons' => [ + ['src' => '/pwa/icon-192.png', 'sizes' => '192x192', 'type' => 'image/png'], + ['src' => '/pwa/icon-512.png', 'sizes' => '512x512', 'type' => 'image/png'], + ['src' => '/pwa/icon-maskable.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'maskable'], + ], + ])->header('Cache-Control', 'public, max-age=3600'); +}); + +// Service worker stub — minimal cache for shell. +Route::get('/sw.js', function () { + return response(<<<'JS' + const CACHE = 'autocrm-shell-v1'; + const SHELL = ['/manifest.json']; + self.addEventListener('install', e => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL))); + }); + self.addEventListener('activate', e => { + e.waitUntil(caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) + )); + }); + self.addEventListener('fetch', e => { + const u = new URL(e.request.url); + if (e.request.method !== 'GET') return; + // network-first for app routes; cache-first for static + if (u.pathname.startsWith('/build/') || u.pathname.startsWith('/pwa/')) { + e.respondWith(caches.match(e.request).then(m => m || fetch(e.request).then(r => { + const copy = r.clone(); + caches.open(CACHE).then(c => c.put(e.request, copy)); + return r; + }))); + } + }); + JS, 200, ['Content-Type' => 'application/javascript', 'Cache-Control' => 'public, max-age=3600']); +});