diff --git a/app/Console/Commands/SendTireSeasonalRemindersCommand.php b/app/Console/Commands/SendTireSeasonalRemindersCommand.php new file mode 100644 index 0000000..77b56dd --- /dev/null +++ b/app/Console/Commands/SendTireSeasonalRemindersCommand.php @@ -0,0 +1,114 @@ +windowFor(today()); + $force = (bool) $this->option('force'); + $dry = (bool) $this->option('dry-run'); + + if (! $window && ! $force) { + $this->info('Outside swap window. Use --force to run anyway. Today: ' . today()->toDateString()); + return self::SUCCESS; + } + + $targetSeason = $window['season'] ?? 'winter'; // season of stored sets we want to notify + + $query = Company::query()->where('status', '!=', 'archived'); + if ($slug = $this->option('slug')) $query->where('slug', $slug); + $companies = $query->get(); + + $totalSent = 0; + $cooldown = today()->subDays(60); + + foreach ($companies as $company) { + app(TenantManager::class)->setCurrent($company); + + // Sets currently in storage whose season matches the window target. + $sets = TireSet::with(['client', 'vehicle', 'storage']) + ->where('season', $targetSeason) + ->whereHas('storage', fn ($s) => $s->where('status', 'stored')) + ->get() + ->filter(fn (TireSet $s) => $s->client && $s->client->status === 'active'); + + $sentThisTenant = 0; + foreach ($sets as $set) { + $recent = ServiceReminderSent::where('type', 'tire_swap') + ->where('client_id', $set->client_id) + ->where('sent_at', '>=', $cooldown) + ->exists(); + if ($recent) continue; + + if ($dry) { + $this->line(sprintf(' - [%s] set #%d %s · client %s · loc %s', + $company->slug, $set->id, $set->sizeLabel(), + $set->client?->name ?? '—', + $set->currentStorage()?->location ?? '—')); + continue; + } + + $ok = $dispatcher->tireSeasonalSwap($set); + if ($ok) { + ServiceReminderSent::create([ + 'company_id' => $company->id, + 'vehicle_id' => $set->vehicle_id, + 'client_id' => $set->client_id, + 'channel' => $set->client?->telegram_chat_id ? 'telegram' : 'email', + 'type' => 'tire_swap', + 'sent_at' => now(), + ]); + $sentThisTenant++; + } + } + $this->info(sprintf('[%s] tire-swap reminders sent: %d', $company->slug, $sentThisTenant)); + $totalSent += $sentThisTenant; + } + + $this->info("Total tire-swap reminders sent: {$totalSent}" . ($dry ? ' (dry run)' : '')); + return self::SUCCESS; + } + + /** Returns ['season' => 'winter'|'summer'] if today is in a swap window, else null. */ + private function windowFor(Carbon $today): ?array + { + // Feb 15 – Mar 15 → notify WINTER sets (swap to summer). + $springStart = Carbon::create($today->year, 2, 15); + $springEnd = Carbon::create($today->year, 3, 15); + if ($today->between($springStart, $springEnd)) return ['season' => 'winter']; + + // Sep 15 – Oct 15 → notify SUMMER sets (swap to winter). + $autumnStart = Carbon::create($today->year, 9, 15); + $autumnEnd = Carbon::create($today->year, 10, 15); + if ($today->between($autumnStart, $autumnEnd)) return ['season' => 'summer']; + + return null; + } +} diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php index 335f800..74bef8f 100644 --- a/app/Filament/Tenant/Resources/PartResource.php +++ b/app/Filament/Tenant/Resources/PartResource.php @@ -99,6 +99,18 @@ class PartResource extends Resource ->options(fn () => Supplier::pluck('name', 'id')) ->searchable(), ]), + Schemas\Components\Section::make('Imagine') + ->collapsible() + ->schema([ + \Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('image') + ->label('Foto piesă') + ->collection('image') + ->image() + ->imageEditor() + ->maxSize(2048) + ->columnSpanFull() + ->helperText('Apare în magazinul online (catalog + pagina piesei). Max 2 MB.'), + ]), Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2), ]); } @@ -107,6 +119,11 @@ class PartResource extends Resource { return $table ->columns([ + \Filament\Tables\Columns\SpatieMediaLibraryImageColumn::make('image') + ->label('') + ->collection('image') + ->circular() + ->size(32), Tables\Columns\TextColumn::make('name')->searchable()->sortable()->wrap(), Tables\Columns\TextColumn::make('article')->label('Cod')->searchable()->copyable()->placeholder('—'), Tables\Columns\TextColumn::make('brand')->placeholder('—'), diff --git a/app/Models/Tenant/Part.php b/app/Models/Tenant/Part.php index 5fe5899..8fcbb21 100644 --- a/app/Models/Tenant/Part.php +++ b/app/Models/Tenant/Part.php @@ -7,10 +7,25 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; -class Part extends Model +class Part extends Model implements HasMedia { - use BelongsToTenant, SoftDeletes; + use BelongsToTenant, InteractsWithMedia, SoftDeletes; + + public function registerMediaCollections(): void + { + $this->addMediaCollection('image')->singleFile(); + } + + public function imageUrl(): ?string + { + $m = $this->getFirstMedia('image'); + if (! $m) return null; + if (! @file_exists($m->getPath())) return null; + return $m->getUrl(); + } public const CATEGORIES = [ 'Ulei', 'Filtre', 'Frâne', 'Suspensie', 'Lichide', diff --git a/app/Services/NotificationDispatcher.php b/app/Services/NotificationDispatcher.php index ed6d971..d011c37 100644 --- a/app/Services/NotificationDispatcher.php +++ b/app/Services/NotificationDispatcher.php @@ -95,6 +95,43 @@ class NotificationDispatcher ]); } + public function tireSeasonalSwap(\App\Models\Tenant\TireSet $set): bool + { + $company = $this->companyFor($set); + $client = $set->client; + if (! $client) return false; + + return $this->dispatch($company, $client, 'reminder', [ + 'telegram' => fn () => $this->tgTireSeasonalSwap($set, $company, $client), + 'email' => fn () => $set->vehicle ? $this->emailSafe( + fn () => Mail::to($client->email)->send(new ServiceReminderMail( + $set->vehicle, + 'tire_swap', + 'E timpul să schimbi anvelopele ' . ($set->season === 'winter' ? 'de iarnă' : 'de vară') . + ' (' . $set->sizeLabel() . ').', + $company + )), + 'tireSeasonalSwap', ['set' => $set->id] + ) : false, + ]); + } + + protected function tgTireSeasonalSwap(\App\Models\Tenant\TireSet $set, Company $company, Client $client): bool + { + $brand = htmlspecialchars($company->display_name ?? $company->name); + $size = htmlspecialchars($set->sizeLabel()); + $seasonRo = $set->season === 'winter' ? 'de iarnă' : 'de vară'; + $loc = $set->currentStorage()?->location; + $plate = $set->vehicle?->plate ? ' · ' . htmlspecialchars($set->vehicle->plate) : ''; + + $text = "🔧 Schimb sezonier anvelope\n" + . "Setul tău {$seasonRo} ({$size}){$plate}" + . ($loc ? " e în depozit la {$loc}." : '.') + . "\n\nProgramează-te la {$brand}."; + + return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text); + } + // ─── Channel dispatch ───────────────────────────────────────── /** diff --git a/resources/views/shop/catalog.blade.php b/resources/views/shop/catalog.blade.php index 089a7d6..5e8c8d5 100644 --- a/resources/views/shop/catalog.blade.php +++ b/resources/views/shop/catalog.blade.php @@ -25,8 +25,15 @@ @else
@foreach ($parts as $p) - @php $stock = (float) $p->qty; @endphp + @php $stock = (float) $p->qty; $img = $p->imageUrl(); @endphp
+ + @if ($img) + {{ $p->name }} + @else +
📦
+ @endif +

{{ $p->name }}

diff --git a/resources/views/shop/layout.blade.php b/resources/views/shop/layout.blade.php index 51e1990..d62d462 100644 --- a/resources/views/shop/layout.blade.php +++ b/resources/views/shop/layout.blade.php @@ -32,6 +32,9 @@ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; } .product { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 14px; display: flex; flex-direction: column; } + .product-thumb { display: block; aspect-ratio: 1; margin-bottom: 10px; border-radius: 8px; overflow: hidden; background: #f9fafb; border: 1px solid #f3f4f6; } + .product-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } + .product-thumb-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 36px; color: #cbd5e1; } .product h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; min-height: 38px; } .product .meta { font-size: 12px; color: #6b7280; margin-bottom: 8px; } .product .price { font-size: 18px; font-weight: 700; color: {{ $themeColor }}; margin-top: auto; } diff --git a/resources/views/shop/part.blade.php b/resources/views/shop/part.blade.php index 1a56b31..6c00343 100644 --- a/resources/views/shop/part.blade.php +++ b/resources/views/shop/part.blade.php @@ -1,11 +1,19 @@ @extends('shop.layout') @section('title', $part->name) @section('content') -@php $currency = $tenant->settings['currency'] ?? 'MDL'; $stock = (float) $part->qty; @endphp +@php $currency = $tenant->settings['currency'] ?? 'MDL'; $stock = (float) $part->qty; $img = $part->imageUrl(); @endphp ← Înapoi la catalog +@if ($img) +
+
+ {{ $part->name }} +
+
+@else
+@endif

{{ $part->name }}

{{ $part->brand ? 'Brand: ' . $part->brand : '' }} @@ -45,5 +53,10 @@

{{ $part->notes }}

@endif -
+@if ($img) +
{{-- /right column --}} +
{{-- /card grid --}} +@else +
{{-- /card --}} +@endif @endsection diff --git a/routes/console.php b/routes/console.php index 3ec6ca6..ba335a7 100644 --- a/routes/console.php +++ b/routes/console.php @@ -30,3 +30,10 @@ ScheduleFacade::command('reminders:send') ->dailyAt('09:00') ->withoutOverlapping() ->onOneServer(); + +// Weekly seasonal tire-swap reminders — Monday 09:30. Self-gates to the +// Feb 15-Mar 15 / Sep 15-Oct 15 windows; outside them it no-ops. +ScheduleFacade::command('tires:remind-seasonal') + ->weeklyOn(1, '09:30') + ->withoutOverlapping() + ->onOneServer(); diff --git a/tests/Feature/TireSeasonalReminderTest.php b/tests/Feature/TireSeasonalReminderTest.php new file mode 100644 index 0000000..438a847 --- /dev/null +++ b/tests/Feature/TireSeasonalReminderTest.php @@ -0,0 +1,139 @@ +makeStoredSet('off', season: 'winter'); + + \Illuminate\Support\Facades\Mail::fake(); + $this->artisan('tires:remind-seasonal')->assertSuccessful(); + + $this->assertEquals(0, ServiceReminderSent::where('type', 'tire_swap')->count()); + Carbon::setTestNow(); + } + + public function test_spring_window_notifies_winter_stored_sets(): void + { + Carbon::setTestNow(Carbon::create(2026, 3, 1)); + $ctx = $this->makeStoredSet('spr', season: 'winter'); + + \Illuminate\Support\Facades\Mail::fake(); + $this->artisan('tires:remind-seasonal')->assertSuccessful(); + + $sent = ServiceReminderSent::where('type', 'tire_swap') + ->where('client_id', $ctx['client']->id)->first(); + $this->assertNotNull($sent); + Carbon::setTestNow(); + } + + public function test_spring_window_ignores_summer_sets(): void + { + Carbon::setTestNow(Carbon::create(2026, 3, 1)); + $this->makeStoredSet('sum', season: 'summer'); + + \Illuminate\Support\Facades\Mail::fake(); + $this->artisan('tires:remind-seasonal')->assertSuccessful(); + + $this->assertEquals(0, ServiceReminderSent::where('type', 'tire_swap')->count()); + Carbon::setTestNow(); + } + + public function test_autumn_window_notifies_summer_stored_sets(): void + { + Carbon::setTestNow(Carbon::create(2026, 10, 1)); + $ctx = $this->makeStoredSet('aut', season: 'summer'); + + \Illuminate\Support\Facades\Mail::fake(); + $this->artisan('tires:remind-seasonal')->assertSuccessful(); + + $sent = ServiceReminderSent::where('type', 'tire_swap')->first(); + $this->assertNotNull($sent); + $this->assertEquals($ctx['client']->id, $sent->client_id); + Carbon::setTestNow(); + } + + public function test_cooldown_prevents_double_send(): void + { + Carbon::setTestNow(Carbon::create(2026, 3, 1)); + $ctx = $this->makeStoredSet('cd', season: 'winter'); + + \Illuminate\Support\Facades\Mail::fake(); + $this->artisan('tires:remind-seasonal')->assertSuccessful(); + $this->assertEquals(1, ServiceReminderSent::where('type', 'tire_swap')->count()); + + // Second run same day → cooldown blocks it. + $this->artisan('tires:remind-seasonal')->assertSuccessful(); + $this->assertEquals(1, ServiceReminderSent::where('type', 'tire_swap')->count()); + Carbon::setTestNow(); + } + + public function test_force_runs_outside_window(): void + { + Carbon::setTestNow(Carbon::create(2026, 6, 2)); + $ctx = $this->makeStoredSet('force', season: 'winter'); + + \Illuminate\Support\Facades\Mail::fake(); + $this->artisan('tires:remind-seasonal', ['--force' => true])->assertSuccessful(); + + $this->assertEquals(1, ServiceReminderSent::where('type', 'tire_swap')->count()); + Carbon::setTestNow(); + } + + private function makeStoredSet(string $slug, string $season): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => $slug, + 'name' => ucfirst($slug), 'status' => 'active', + 'settings' => ['telegram' => ['bot_token' => 'FAKE:TOKEN']], + ]); + app(TenantManager::class)->setCurrent($company); + + $client = Client::create([ + 'name' => 'TireClient', 'phone' => '+3736' . random_int(1000000, 9999999), + 'email' => $slug . '@example.com', + 'telegram_chat_id' => '9999' . random_int(1000, 9999), + 'type' => 'individual', 'status' => 'active', + ]); + + $vehicle = \App\Models\Tenant\Vehicle::create([ + 'client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', + 'plate' => 'TS-' . random_int(100, 999), + ]); + + $set = TireSet::create([ + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'season' => $season, 'brand' => 'Michelin', + 'width' => 205, 'profile' => 55, 'diameter' => 16, + ]); + TireStorage::create([ + 'tire_set_id' => $set->id, 'location' => 'A1-' . $slug, + 'status' => 'stored', 'checked_in_at' => now()->subMonths(2), + ]); + + // Telegram dispatch goes through HTTP; fake it so the test doesn't + // hit the real Bot API. + \Illuminate\Support\Facades\Http::fake([ + 'api.telegram.org/*' => \Illuminate\Support\Facades\Http::response(['ok' => true]), + ]); + + return compact('company', 'client', 'vehicle', 'set'); + } +}