diff --git a/app/Filament/Tenant/Resources/ClientResource.php b/app/Filament/Tenant/Resources/ClientResource.php index f0a99c7..04c4266 100644 --- a/app/Filament/Tenant/Resources/ClientResource.php +++ b/app/Filament/Tenant/Resources/ClientResource.php @@ -26,6 +26,21 @@ class ClientResource extends Resource protected static ?int $navigationSort = 10; + protected static ?string $recordTitleAttribute = 'name'; + + public static function getGloballySearchableAttributes(): array + { + return ['name', 'phone', 'phone_alt', 'email', 'company_name']; + } + + public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array + { + return [ + 'Telefon' => $record->phone, + 'Status' => $record->status, + ]; + } + public static function form(Schema $schema): Schema { return $schema->components([ diff --git a/app/Filament/Tenant/Resources/LeadResource.php b/app/Filament/Tenant/Resources/LeadResource.php index 687cede..92f08c8 100644 --- a/app/Filament/Tenant/Resources/LeadResource.php +++ b/app/Filament/Tenant/Resources/LeadResource.php @@ -30,6 +30,19 @@ class LeadResource extends Resource protected static ?int $navigationSort = 5; + public static function getGloballySearchableAttributes(): array + { + return ['name', 'phone', 'email', 'channel']; + } + + public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array + { + return [ + 'Telefon' => $record->phone, + 'Status' => $record->status, + ]; + } + public static function form(Schema $schema): Schema { return $schema->components([ diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php index e08084e..9d821ec 100644 --- a/app/Filament/Tenant/Resources/PartResource.php +++ b/app/Filament/Tenant/Resources/PartResource.php @@ -29,6 +29,19 @@ class PartResource extends Resource protected static ?int $navigationSort = 40; + public static function getGloballySearchableAttributes(): array + { + return ['name', 'sku', 'brand', 'category']; + } + + public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array + { + return [ + 'Stoc' => (int) $record->stock . ' ' . ($record->unit ?? 'buc'), + 'Preț' => number_format((float) $record->sell_price, 2), + ]; + } + public static function getNavigationBadge(): ?string { $low = static::getModel()::query() diff --git a/app/Filament/Tenant/Resources/VehicleResource.php b/app/Filament/Tenant/Resources/VehicleResource.php index fece9f0..1723815 100644 --- a/app/Filament/Tenant/Resources/VehicleResource.php +++ b/app/Filament/Tenant/Resources/VehicleResource.php @@ -27,6 +27,24 @@ class VehicleResource extends Resource protected static ?int $navigationSort = 20; + public static function getGloballySearchableAttributes(): array + { + return ['plate', 'vin', 'brand', 'model']; + } + + public static function getGlobalSearchResultTitle(\Illuminate\Database\Eloquent\Model $record): string + { + return trim(($record->brand ?? '') . ' ' . ($record->model ?? '') . ' — ' . ($record->plate ?? $record->vin ?? '?')); + } + + public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array + { + return [ + 'Client' => $record->client?->name ?? '—', + 'An' => $record->year ?? '—', + ]; + } + public static function form(Schema $schema): Schema { return $schema->components([ diff --git a/app/Filament/Tenant/Resources/WorkOrderResource.php b/app/Filament/Tenant/Resources/WorkOrderResource.php index 479aab0..d60d241 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource.php @@ -34,6 +34,25 @@ class WorkOrderResource extends Resource protected static ?int $navigationSort = 30; + public static function getGloballySearchableAttributes(): array + { + return ['number', 'description', 'vehicle.plate', 'vehicle.vin', 'client.name', 'client.phone']; + } + + public static function getGlobalSearchResultTitle(\Illuminate\Database\Eloquent\Model $record): string + { + return '#' . ($record->number ?? $record->id) . ' · ' . ($record->vehicle?->plate ?? '?'); + } + + public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array + { + return [ + 'Client' => $record->client?->name ?? '—', + 'Status' => $record->status, + 'Total' => number_format((float) $record->total, 2), + ]; + } + public static function form(Schema $schema): Schema { return $schema->components([ diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..5a42721 --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,46 @@ +resolve($request); + + App::setLocale($locale); + Carbon::setLocale($locale); + + return $next($request); + } + + private function resolve(Request $request): string + { + $session = $request->session()->get('locale'); + if ($session && in_array($session, self::SUPPORTED, true)) { + return $session; + } + + $user = Auth::user(); + if ($user && ! empty($user->locale) && in_array($user->locale, self::SUPPORTED, true)) { + return $user->locale; + } + + $tenant = app(TenantManager::class)->current(); + $tenantLang = $tenant?->settings['language'] ?? null; + if ($tenantLang && in_array($tenantLang, self::SUPPORTED, true)) { + return $tenantLang; + } + + return config('app.locale', 'ro'); + } +} diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index 13c7480..374d66d 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -38,6 +38,9 @@ class TenantPanelProvider extends PanelProvider 'primary' => Color::Blue, ]) ->authGuard('web') + ->databaseNotifications() + ->databaseNotificationsPolling('30s') + ->globalSearchKeyBindings(['command+k', 'ctrl+k']) ->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([ @@ -58,6 +61,7 @@ class TenantPanelProvider extends PanelProvider // unauthenticated → endless redirect to /app/login. ResolveTenant::class, CheckTenantStatus::class, + \App\Http\Middleware\SetLocale::class, EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, @@ -114,6 +118,68 @@ class TenantPanelProvider extends PanelProvider BLADE) ) + ->renderHook( + PanelsRenderHook::USER_MENU_BEFORE, + fn (): string => Blade::render(<<<'BLADE' + @php + $locale = app()->getLocale(); + $langs = ['ro' => 'RO', 'ru' => 'RU', 'en' => 'EN']; + $csrf = csrf_token(); + @endphp +
+ +
+ @foreach ($langs as $code => $label) +
+ + +
+ @endforeach +
+
+ + BLADE) + ) ->renderHook( PanelsRenderHook::SIDEBAR_LOGO_BEFORE, fn (): string => Blade::render(<<<'BLADE' diff --git a/bootstrap/app.php b/bootstrap/app.php index 8d9245a..40d8990 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -29,6 +29,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web(append: [ \App\Http\Middleware\ResolveTenant::class, \App\Http\Middleware\CheckTenantStatus::class, + \App\Http\Middleware\SetLocale::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_05_08_000001_create_notifications_table.php b/database/migrations/2026_05_08_000001_create_notifications_table.php new file mode 100644 index 0000000..9793f46 --- /dev/null +++ b/database/migrations/2026_05_08_000001_create_notifications_table.php @@ -0,0 +1,24 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..ffaae03 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,57 @@ +{ + "Dashboard": "Dashboard", + "Save": "Save", + "Cancel": "Cancel", + "Delete": "Delete", + "Edit": "Edit", + "Create": "Create", + "Search": "Search", + "Filters": "Filters", + "Reset": "Reset", + "Yes": "Yes", + "No": "No", + "Loading": "Loading...", + "Empty": "No results", + "Settings": "Settings", + "Profile": "Profile", + "Logout": "Logout", + "Welcome": "Welcome", + "Total": "Total", + "Date": "Date", + "Status": "Status", + "Actions": "Actions", + "Notifications": "Notifications", + + "Clienți": "Clients", + "Mașini": "Vehicles", + "Cereri": "Leads", + "Pâlnie": "Pipeline", + "Calendar": "Calendar", + "Programări": "Appointments", + "Fișe lucru": "Work orders", + "Kanban": "Kanban", + "Norme-ore": "Labor times", + "Depozit": "Inventory", + "Furnizori": "Suppliers", + "Achiziții": "Purchases", + "Plăți": "Payments", + "Cheltuieli": "Expenses", + "Salarii": "Payroll", + "Tehnicieni": "Technicians", + "Marketing": "Marketing", + "Mesaje": "Messages", + "Rapoarte": "Reports", + "Recomandări": "Recommendations", + "Încărcare STO": "Workshop load", + "Procentaj": "Markup", + "VIN-căutare": "VIN search", + "Integrări": "Integrations", + "Backup": "Backup", + "Asistent AI": "AI Assistant", + "Setări companie": "Company settings", + "Utilizatori": "Users", + "Jurnal": "Audit log", + "Telefonie": "Calls", + "Finanțe": "Finance", + "Site PSauto": "Public site" +} diff --git a/lang/ro.json b/lang/ro.json new file mode 100644 index 0000000..92e6106 --- /dev/null +++ b/lang/ro.json @@ -0,0 +1,24 @@ +{ + "Dashboard": "Tablou de bord", + "Save": "Salvează", + "Cancel": "Anulează", + "Delete": "Șterge", + "Edit": "Editează", + "Create": "Creează", + "Search": "Caută", + "Filters": "Filtre", + "Reset": "Resetează", + "Yes": "Da", + "No": "Nu", + "Loading": "Se încarcă...", + "Empty": "Niciun rezultat", + "Settings": "Setări", + "Profile": "Profil", + "Logout": "Ieșire", + "Welcome": "Bine ai venit", + "Total": "Total", + "Date": "Dată", + "Status": "Status", + "Actions": "Acțiuni", + "Notifications": "Notificări" +} diff --git a/lang/ru.json b/lang/ru.json new file mode 100644 index 0000000..1444456 --- /dev/null +++ b/lang/ru.json @@ -0,0 +1,57 @@ +{ + "Dashboard": "Панель", + "Save": "Сохранить", + "Cancel": "Отмена", + "Delete": "Удалить", + "Edit": "Изменить", + "Create": "Создать", + "Search": "Поиск", + "Filters": "Фильтры", + "Reset": "Сбросить", + "Yes": "Да", + "No": "Нет", + "Loading": "Загрузка...", + "Empty": "Нет результатов", + "Settings": "Настройки", + "Profile": "Профиль", + "Logout": "Выход", + "Welcome": "Добро пожаловать", + "Total": "Итого", + "Date": "Дата", + "Status": "Статус", + "Actions": "Действия", + "Notifications": "Уведомления", + + "Clienți": "Клиенты", + "Mașini": "Машины", + "Cereri": "Заявки", + "Pâlnie": "Воронка", + "Calendar": "Календарь", + "Programări": "Записи", + "Fișe lucru": "Рабочие листы", + "Kanban": "Канбан", + "Norme-ore": "Нормо-часы", + "Depozit": "Склад", + "Furnizori": "Поставщики", + "Achiziții": "Закупки", + "Plăți": "Платежи", + "Cheltuieli": "Расходы", + "Salarii": "Зарплаты", + "Tehnicieni": "Техники", + "Marketing": "Маркетинг", + "Mesaje": "Сообщения", + "Rapoarte": "Отчёты", + "Recomandări": "Рекомендации", + "Încărcare STO": "Загрузка СТО", + "Procentaj": "Наценка", + "VIN-căutare": "VIN-поиск", + "Integrări": "Интеграции", + "Backup": "Бэкап", + "Asistent AI": "AI Ассистент", + "Setări companie": "Настройки компании", + "Utilizatori": "Пользователи", + "Jurnal": "Журнал", + "Telefonie": "Телефония", + "Finanțe": "Финансы", + "Site PSauto": "Сайт" +} diff --git a/routes/web.php b/routes/web.php index bc7dcef..d540e32 100644 --- a/routes/web.php +++ b/routes/web.php @@ -24,6 +24,18 @@ Route::get('/', function () { return redirect('/admin'); }); +// Locale switch — POST /locale/{lang} sets session and persists to user. +Route::post('/locale/{lang}', function (Request $request, string $lang) { + if (! in_array($lang, ['ro', 'ru', 'en'], true)) { + abort(404); + } + $request->session()->put('locale', $lang); + if ($u = $request->user()) { + $u->forceFill(['locale' => $lang])->saveQuietly(); + } + return back(); +})->name('locale.switch'); + // PWA — manifest dinamic per tenant. Route::get('/manifest.json', function (Request $request) { $tenant = app(TenantManager::class)->current(); diff --git a/tests/Feature/AuthFlowTest.php b/tests/Feature/AuthFlowTest.php new file mode 100644 index 0000000..339cc5b --- /dev/null +++ b/tests/Feature/AuthFlowTest.php @@ -0,0 +1,77 @@ + 'Test', 'slug' => 'test', 'price' => 0, 'features' => [], + ]); + $companyA = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'company-a-' . uniqid(), + 'name' => 'A', 'status' => 'active', + ]); + $companyB = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'company-b-' . uniqid(), + 'name' => 'B', 'status' => 'active', + ]); + + app(TenantManager::class)->setCurrent($companyA); + $userA = User::create([ + 'company_id' => $companyA->id, + 'name' => 'Alice', + 'email' => 'alice@a.com', + 'password' => Hash::make('secret123'), + 'status' => 'active', + ]); + + // Switch to company B context — try to attempt() with A's credentials + app(TenantManager::class)->setCurrent($companyB); + $ok = auth('web')->attempt(['email' => 'alice@a.com', 'password' => 'secret123']); + + $this->assertFalse($ok, 'User from company A authenticated successfully on company B subdomain'); + } + + public function test_user_can_login_on_own_subdomain(): void + { + $plan = Plan::create([ + 'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [], + ]); + $company = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'mine-' . uniqid(), + 'name' => 'Mine', 'status' => 'active', + ]); + + app(TenantManager::class)->setCurrent($company); + User::create([ + 'company_id' => $company->id, + 'name' => 'Bob', + 'email' => 'bob@mine.com', + 'password' => Hash::make('pwd12345'), + 'status' => 'active', + ]); + + $ok = auth('web')->attempt(['email' => 'bob@mine.com', 'password' => 'pwd12345']); + + $this->assertTrue($ok); + } +} diff --git a/tests/Feature/TenantIsolationTest.php b/tests/Feature/TenantIsolationTest.php new file mode 100644 index 0000000..ef4a326 --- /dev/null +++ b/tests/Feature/TenantIsolationTest.php @@ -0,0 +1,114 @@ +makeTwoCompanies(); + + // Create client in company A + app(TenantManager::class)->setCurrent($companyA); + $clientA = Client::create([ + 'name' => 'Alice', 'phone' => '+37300001', + 'type' => 'individual', 'status' => 'active', + ]); + + // Switch to company B and try to read + app(TenantManager::class)->setCurrent($companyB); + $found = Client::find($clientA->id); + + $this->assertNull($found, 'Client from company A leaked into company B query'); + } + + public function test_tenant_scope_returns_empty_when_no_tenant(): void + { + [$companyA] = $this->makeTwoCompanies(); + + app(TenantManager::class)->setCurrent($companyA); + Client::create([ + 'name' => 'X', 'phone' => '+37300002', + 'type' => 'individual', 'status' => 'active', + ]); + + // Reset tenant — fail-safe must engage + app(TenantManager::class)->setCurrent(null); + $count = Client::query()->count(); + + $this->assertSame(0, $count, 'TenantScope did not engage WHERE 1=0 fail-safe'); + } + + public function test_user_email_can_repeat_across_tenants(): void + { + [$companyA, $companyB] = $this->makeTwoCompanies(); + + app(TenantManager::class)->setCurrent($companyA); + $userA = User::create([ + 'company_id' => $companyA->id, + 'name' => 'A', 'email' => 'shared@example.com', + 'password' => 'pwd', 'status' => 'active', + ]); + + app(TenantManager::class)->setCurrent($companyB); + $userB = User::create([ + 'company_id' => $companyB->id, + 'name' => 'B', 'email' => 'shared@example.com', + 'password' => 'pwd', 'status' => 'active', + ]); + + $this->assertNotEquals($userA->id, $userB->id); + $this->assertSame('shared@example.com', $userA->email); + $this->assertSame('shared@example.com', $userB->email); + } + + public function test_creating_model_auto_fills_company_id(): void + { + [$companyA] = $this->makeTwoCompanies(); + + app(TenantManager::class)->setCurrent($companyA); + $client = Client::create([ + 'name' => 'Auto', 'phone' => '+37300003', + 'type' => 'individual', 'status' => 'active', + ]); + + $this->assertSame($companyA->id, $client->company_id, 'BelongsToTenant trait did not auto-fill company_id'); + } + + private function makeTwoCompanies(): array + { + $plan = Plan::create([ + 'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [], + ]); + + $a = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'aaa-' . uniqid(), + 'name' => 'AAA Service', + 'status' => 'active', + ]); + + $b = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'bbb-' . uniqid(), + 'name' => 'BBB Service', + 'status' => 'active', + ]); + + return [$a, $b]; + } +} diff --git a/tests/Feature/WorkOrderCalcTest.php b/tests/Feature/WorkOrderCalcTest.php new file mode 100644 index 0000000..493538c --- /dev/null +++ b/tests/Feature/WorkOrderCalcTest.php @@ -0,0 +1,111 @@ +makeWorkOrder(); + + WorkOrderWork::create([ + 'work_order_id' => $wo->id, + 'name' => 'Schimb plăcuțe', + 'hours' => 2, + 'price_per_hour' => 400, + 'status' => 'todo', + ]); + + $wo->refresh(); + $this->assertEquals(800.00, (float) $wo->total); + } + + public function test_total_includes_works_plus_parts(): void + { + $wo = $this->makeWorkOrder(); + + WorkOrderWork::create([ + 'work_order_id' => $wo->id, + 'name' => 'Manoperă', + 'hours' => 1, + 'price_per_hour' => 400, + 'status' => 'todo', + ]); + + WorkOrderPart::create([ + 'work_order_id' => $wo->id, + 'name' => 'Filtru', + 'qty' => 2, + 'price' => 150, + 'total' => 300, + 'status' => 'pending', + ]); + + $wo->refresh(); + $this->assertEquals(700.00, (float) $wo->total); + } + + public function test_discount_applies_to_total(): void + { + $wo = $this->makeWorkOrder(); + $wo->update(['discount_pct' => 10]); + + WorkOrderWork::create([ + 'work_order_id' => $wo->id, + 'name' => 'X', + 'hours' => 1, + 'price_per_hour' => 1000, + 'status' => 'todo', + ]); + + $wo->refresh(); + $this->assertEquals(900.00, (float) $wo->total); + } + + private function makeWorkOrder(): WorkOrder + { + $plan = Plan::create(['name' => 'P', 'slug' => 'p', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'wo-' . uniqid(), + 'name' => 'WO Test', + 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + + $client = Client::create([ + 'name' => 'Test', 'phone' => '+1', 'type' => 'individual', 'status' => 'active', + ]); + $vehicle = Vehicle::create([ + 'client_id' => $client->id, + 'plate' => 'TST 001', + 'brand' => 'Test', + 'model' => 'X', + ]); + + return WorkOrder::create([ + 'client_id' => $client->id, + 'vehicle_id' => $vehicle->id, + 'number' => 'WO-001', + 'opened_at' => now(), + 'status' => 'new', + 'pay_status' => 'unpaid', + 'discount_pct' => 0, + 'total' => 0, + ]); + } +} diff --git a/tests/Unit/MarkupRuleTest.php b/tests/Unit/MarkupRuleTest.php new file mode 100644 index 0000000..82fdb49 --- /dev/null +++ b/tests/Unit/MarkupRuleTest.php @@ -0,0 +1,103 @@ +setupTenant(); + + $part = Part::create([ + 'name' => 'Plăcuțe frână', + 'category' => 'Frâne', + 'buy_price' => 100, + 'sell_price' => 100, + 'stock' => 10, + 'is_active' => true, + ]); + + MarkupRule::create([ + 'type' => 'category', + 'key' => 'Frâne', + 'markup_pct' => 35, + 'priority' => 100, + 'is_active' => true, + ]); + + MarkupRule::applyToPart($part); + $part->refresh(); + + $this->assertEquals(135.00, (float) $part->sell_price); + } + + public function test_priority_picks_first_matching_rule(): void + { + $this->setupTenant(); + + $part = Part::create([ + 'name' => 'Filtru', + 'category' => 'Filtre', + 'brand' => 'Bosch', + 'buy_price' => 50, + 'sell_price' => 50, + 'stock' => 5, + 'is_active' => true, + ]); + + // Brand rule (priority 1 → applied first) + MarkupRule::create(['type' => 'brand', 'key' => 'Bosch', 'markup_pct' => 50, 'priority' => 1, 'is_active' => true]); + MarkupRule::create(['type' => 'category', 'key' => 'Filtre', 'markup_pct' => 20, 'priority' => 100, 'is_active' => true]); + + MarkupRule::applyToPart($part); + $part->refresh(); + + $this->assertEquals(75.00, (float) $part->sell_price); + } + + public function test_inactive_rule_is_skipped(): void + { + $this->setupTenant(); + + $part = Part::create([ + 'name' => 'X', 'category' => 'Test', + 'buy_price' => 100, 'sell_price' => 100, + 'stock' => 1, 'is_active' => true, + ]); + + MarkupRule::create([ + 'type' => 'category', 'key' => 'Test', + 'markup_pct' => 99, 'priority' => 1, 'is_active' => false, + ]); + + MarkupRule::applyToPart($part); + $part->refresh(); + + // Inactive rule skipped → fallback default markup 30%. + $this->assertEquals(130.00, (float) $part->sell_price); + } + + private function setupTenant(): void + { + $plan = Plan::create([ + 'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [], + ]); + $company = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'unit-' . uniqid(), + 'name' => 'Unit Test', + 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + } +}