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
{{ $part->notes }}