diff --git a/app/Filament/Tenant/Resources/TireSetResource.php b/app/Filament/Tenant/Resources/TireSetResource.php new file mode 100644 index 0000000..39169f9 --- /dev/null +++ b/app/Filament/Tenant/Resources/TireSetResource.php @@ -0,0 +1,196 @@ +count(); + return $stored > 0 ? (string) $stored : null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'info'; + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Schemas\Components\Section::make('Proprietar') + ->columns(2) + ->schema([ + Forms\Components\Select::make('client_id') + ->label('Client') + ->options(fn () => Client::pluck('name', 'id')) + ->searchable() + ->live() + ->required(), + Forms\Components\Select::make('vehicle_id') + ->label('Auto') + ->options(fn (Get $get) => $get('client_id') + ? Vehicle::where('client_id', $get('client_id'))->get() + ->mapWithKeys(fn ($v) => [$v->id => "{$v->make} {$v->model} {$v->plate}"])->toArray() + : []) + ->searchable(), + Forms\Components\TextInput::make('label')->label('Etichetă')->placeholder('ex: Iarnă Michelin'), + Forms\Components\Select::make('season')->label('Sezon')->options(TireSet::SEASONS)->default('winter')->required(), + ]), + Schemas\Components\Section::make('Specificații') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('width')->label('Lățime')->numeric()->placeholder('205'), + Forms\Components\TextInput::make('profile')->label('Profil')->numeric()->placeholder('55'), + Forms\Components\TextInput::make('diameter')->label('Diametru R')->numeric()->placeholder('16'), + Forms\Components\TextInput::make('brand')->maxLength(64), + Forms\Components\TextInput::make('model')->maxLength(64), + Forms\Components\TextInput::make('dot_year')->label('DOT')->maxLength(8)->placeholder('3621'), + Forms\Components\Toggle::make('has_rims')->label('Cu jante'), + Forms\Components\Select::make('rim_type')->label('Tip jante')->options(['steel' => 'Tablă', 'alloy' => 'Aliaj']), + Forms\Components\Select::make('condition')->label('Stare')->options(TireSet::CONDITIONS), + ]), + Schemas\Components\Section::make('Uzură (mm) per poziție') + ->columns(4) + ->schema([ + Forms\Components\TextInput::make('tread.fl')->label('Față-Stânga')->numeric(), + Forms\Components\TextInput::make('tread.fr')->label('Față-Dreapta')->numeric(), + Forms\Components\TextInput::make('tread.rl')->label('Spate-Stânga')->numeric(), + Forms\Components\TextInput::make('tread.rr')->label('Spate-Dreapta')->numeric(), + ]), + Schemas\Components\Section::make('TPMS & foto') + ->columns(2) + ->schema([ + Forms\Components\Toggle::make('tpms')->label('Senzori TPMS'), + Forms\Components\TextInput::make('notes')->label('Observații'), + \Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos') + ->label('Fotografii') + ->collection('photos') + ->multiple() + ->image() + ->maxFiles(8) + ->columnSpanFull(), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable()->sortable(), + Tables\Columns\TextColumn::make('label')->label('Etichetă')->placeholder('—'), + Tables\Columns\TextColumn::make('size') + ->label('Dimensiune') + ->state(fn (TireSet $r) => $r->sizeLabel()), + Tables\Columns\TextColumn::make('season') + ->label('Sezon') + ->formatStateUsing(fn ($s) => TireSet::SEASONS[$s] ?? $s) + ->badge() + ->colors(['warning' => ['summer'], 'info' => ['winter'], 'gray' => ['allseason']]), + Tables\Columns\TextColumn::make('tread_min')->label('Uzură min') + ->formatStateUsing(fn ($s) => $s ? $s . ' mm' : '—') + ->color(fn ($s) => $s !== null && (float) $s < 3 ? 'danger' : null) + ->alignRight(), + Tables\Columns\IconColumn::make('tpms')->label('TPMS')->boolean()->toggleable(), + Tables\Columns\TextColumn::make('storage_status') + ->label('Depozit') + ->state(fn (TireSet $r) => $r->isStored() ? ($r->currentStorage()?->location ?? 'da') : '—') + ->badge() + ->color(fn ($state) => $state === '—' ? 'gray' : 'success'), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('season')->options(TireSet::SEASONS), + Tables\Filters\Filter::make('stored') + ->label('În depozit') + ->query(fn ($q) => $q->whereHas('storage', fn ($s) => $s->where('status', 'stored'))), + ]) + ->actions([ + Actions\Action::make('check_in') + ->label('Check-in depozit') + ->icon('heroicon-m-arrow-down-on-square') + ->color('success') + ->visible(fn (TireSet $r) => ! $r->isStored()) + ->schema([ + Forms\Components\TextInput::make('location')->label('Locație (raft)')->required()->placeholder('A1-03'), + Forms\Components\TextInput::make('season_label')->label('Perioadă')->placeholder('Iarnă 2025-2026'), + Forms\Components\TextInput::make('fee')->label('Taxă depozitare')->numeric()->default(0), + ]) + ->action(function (TireSet $r, array $data) { + \App\Models\Tenant\TireStorage::create([ + 'tire_set_id' => $r->id, + 'location' => $data['location'], + 'season_label' => $data['season_label'] ?? null, + 'fee' => (float) ($data['fee'] ?? 0), + 'status' => 'stored', + 'checked_in_at' => now(), + ]); + \Filament\Notifications\Notification::make()->title('Set primit în depozit')->success()->send(); + }), + Actions\Action::make('check_out') + ->label('Eliberează') + ->icon('heroicon-m-arrow-up-on-square') + ->color('warning') + ->visible(fn (TireSet $r) => $r->isStored()) + ->requiresConfirmation() + ->modalDescription('Marchează setul ca ridicat de client.') + ->action(function (TireSet $r) { + $storage = $r->currentStorage(); + if ($storage) { + $storage->update(['status' => 'retrieved', 'checked_out_at' => now()]); + } + \Filament\Notifications\Notification::make()->title('Set eliberat din depozit')->success()->send(); + }), + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Niciun set de anvelope') + ->emptyStateDescription('Înregistrează seturile de anvelope ale clienților și gestionează depozitarea sezonieră (tire hotel). Urmărește uzura, TPMS și locația în depozit.') + ->emptyStateIcon('heroicon-o-lifebuoy') + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\StorageRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTireSets::route('/'), + 'create' => Pages\CreateTireSet::route('/create'), + 'edit' => Pages\EditTireSet::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/TireSetResource/Pages/CreateTireSet.php b/app/Filament/Tenant/Resources/TireSetResource/Pages/CreateTireSet.php new file mode 100644 index 0000000..fb4dac4 --- /dev/null +++ b/app/Filament/Tenant/Resources/TireSetResource/Pages/CreateTireSet.php @@ -0,0 +1,11 @@ +columns([ + Tables\Columns\TextColumn::make('season_label')->label('Perioadă')->placeholder('—'), + Tables\Columns\TextColumn::make('location')->label('Locație')->placeholder('—'), + Tables\Columns\TextColumn::make('checked_in_at')->label('Primit')->dateTime('d.m.Y'), + Tables\Columns\TextColumn::make('checked_out_at')->label('Ridicat')->dateTime('d.m.Y')->placeholder('—'), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($s) => TireStorage::STATUSES[$s] ?? $s) + ->badge() + ->colors(['success' => ['stored'], 'gray' => ['retrieved']]), + Tables\Columns\TextColumn::make('fee')->money('MDL')->alignRight(), + Tables\Columns\IconColumn::make('paid')->label('Plătit')->boolean(), + ]) + ->defaultSort('checked_in_at', 'desc') + ->emptyStateHeading('Niciun istoric') + ->emptyStateDescription('Folosește „Check-in depozit" pe set pentru a înregistra prima depozitare.'); + } +} diff --git a/app/Models/Tenant/TireSet.php b/app/Models/Tenant/TireSet.php new file mode 100644 index 0000000..da6343a --- /dev/null +++ b/app/Models/Tenant/TireSet.php @@ -0,0 +1,98 @@ + 'Vară', + 'winter' => 'Iarnă', + 'allseason' => 'All-season', + ]; + + public const CONDITIONS = [ + 'nou' => 'Nou', + 'bun' => 'Bun', + 'uzat' => 'Uzat', + 'critic' => 'Critic', + ]; + + protected $fillable = [ + 'company_id', 'client_id', 'vehicle_id', + 'label', 'season', + 'width', 'profile', 'diameter', 'brand', 'model', 'dot_year', + 'has_rims', 'rim_type', + 'tread', 'tread_min', 'tpms', 'tpms_ids', + 'condition', 'notes', + ]; + + protected $casts = [ + 'tread' => 'array', + 'tpms_ids' => 'array', + 'tread_min' => 'decimal:1', + 'has_rims' => 'boolean', + 'tpms' => 'boolean', + ]; + + public function registerMediaCollections(): void + { + $this->addMediaCollection('photos'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function storage(): HasMany + { + return $this->hasMany(TireStorage::class); + } + + public function currentStorage(): ?TireStorage + { + return $this->storage()->where('status', 'stored')->latest('checked_in_at')->first(); + } + + public function isStored(): bool + { + return $this->storage()->where('status', 'stored')->exists(); + } + + public function sizeLabel(): string + { + if (! $this->width || ! $this->profile || ! $this->diameter) { + return '—'; + } + return "{$this->width}/{$this->profile} R{$this->diameter}"; + } + + /** Recompute tread_min from the per-position tread JSON. */ + public function recomputeTreadMin(): void + { + $vals = array_filter(array_map('floatval', array_values((array) $this->tread)), fn ($v) => $v > 0); + $this->tread_min = $vals ? min($vals) : null; + } + + protected static function booted(): void + { + static::saving(function (self $set) { + $set->recomputeTreadMin(); + }); + } +} diff --git a/app/Models/Tenant/TireStorage.php b/app/Models/Tenant/TireStorage.php new file mode 100644 index 0000000..aec6c89 --- /dev/null +++ b/app/Models/Tenant/TireStorage.php @@ -0,0 +1,48 @@ + 'În depozit', + 'retrieved' => 'Ridicat', + ]; + + protected $fillable = [ + 'company_id', 'tire_set_id', + 'location', 'season_label', 'status', + 'checked_in_at', 'checked_out_at', 'fee', 'paid', 'notes', + ]; + + protected $casts = [ + 'checked_in_at' => 'datetime', + 'checked_out_at' => 'datetime', + 'fee' => 'decimal:2', + 'paid' => 'boolean', + ]; + + public function tireSet(): BelongsTo + { + return $this->belongsTo(TireSet::class); + } + + public function isActive(): bool + { + return $this->status === 'stored'; + } + + public function durationDays(): int + { + $end = $this->checked_out_at ?? now(); + return (int) $this->checked_in_at?->diffInDays($end); + } +} diff --git a/database/migrations/2026_05_28_190000_create_tire_service_tables.php b/database/migrations/2026_05_28_190000_create_tire_service_tables.php new file mode 100644 index 0000000..0f6b423 --- /dev/null +++ b/database/migrations/2026_05_28_190000_create_tire_service_tables.php @@ -0,0 +1,73 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('vehicle_id')->nullable()->constrained()->nullOnDelete(); + + $t->string('label')->nullable(); // ex "Iarnă Michelin" + $t->string('season', 16)->default('winter'); // summer / winter / allseason + + // Size: 205/55 R16 + $t->unsignedSmallInteger('width')->nullable(); // 205 + $t->unsignedSmallInteger('profile')->nullable(); // 55 + $t->unsignedSmallInteger('diameter')->nullable();// 16 + $t->string('brand', 64)->nullable(); + $t->string('model', 64)->nullable(); + $t->string('dot_year', 8)->nullable(); // DOT week/year + + $t->boolean('has_rims')->default(false); + $t->string('rim_type', 32)->nullable(); // steel / alloy + + $t->json('tread')->nullable(); // {fl, fr, rl, rr} mm + $t->decimal('tread_min', 4, 1)->nullable(); // cached min for sorting/alerts + + $t->boolean('tpms')->default(false); + $t->json('tpms_ids')->nullable(); // sensor ids per position + + $t->string('condition', 24)->nullable(); // nou / bun / uzat / critic + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'client_id']); + $t->index(['company_id', 'season']); + }); + + Schema::create('tire_storage', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('tire_set_id')->constrained()->cascadeOnDelete(); + + $t->string('location', 64)->nullable(); // rack/shelf code + $t->string('season_label', 32)->nullable(); // "Iarnă 2025-2026" + $t->string('status', 16)->default('stored'); // stored / retrieved + $t->dateTime('checked_in_at'); + $t->dateTime('checked_out_at')->nullable(); + $t->decimal('fee', 10, 2)->default(0); + $t->boolean('paid')->default(false); + $t->text('notes')->nullable(); + + $t->timestamps(); + + $t->index(['company_id', 'status']); + $t->index(['company_id', 'tire_set_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tire_storage'); + Schema::dropIfExists('tire_sets'); + } +}; diff --git a/tests/Feature/TireServiceTest.php b/tests/Feature/TireServiceTest.php new file mode 100644 index 0000000..55bf0d9 --- /dev/null +++ b/tests/Feature/TireServiceTest.php @@ -0,0 +1,107 @@ +company = $this->makeCompany('tires'); + } + + public function test_size_label_formats_correctly(): void + { + $set = $this->makeSet(['width' => 205, 'profile' => 55, 'diameter' => 16]); + $this->assertEquals('205/55 R16', $set->sizeLabel()); + + $incomplete = $this->makeSet(['width' => 205, 'profile' => null, 'diameter' => 16]); + $this->assertEquals('—', $incomplete->sizeLabel()); + } + + public function test_tread_min_computed_on_save(): void + { + $set = $this->makeSet(['tread' => ['fl' => 6.5, 'fr' => 6.0, 'rl' => 4.2, 'rr' => 4.8]]); + $this->assertEquals(4.2, (float) $set->tread_min); + } + + public function test_check_in_creates_active_storage(): void + { + $set = $this->makeSet(); + TireStorage::create([ + 'tire_set_id' => $set->id, + 'location' => 'A1-03', + 'season_label' => 'Iarnă 2025-2026', + 'fee' => 500, + 'status' => 'stored', + 'checked_in_at' => now(), + ]); + + $this->assertTrue($set->fresh()->isStored()); + $this->assertEquals('A1-03', $set->fresh()->currentStorage()->location); + } + + public function test_check_out_marks_retrieved(): void + { + $set = $this->makeSet(); + $storage = TireStorage::create([ + 'tire_set_id' => $set->id, 'location' => 'B2', 'status' => 'stored', 'checked_in_at' => now()->subMonths(5), + ]); + + $storage->update(['status' => 'retrieved', 'checked_out_at' => now()]); + + $this->assertFalse($set->fresh()->isStored()); + $this->assertEquals('retrieved', $storage->fresh()->status); + $this->assertGreaterThanOrEqual(140, $storage->fresh()->durationDays()); + } + + public function test_set_can_have_multiple_storage_stays(): void + { + $set = $this->makeSet(); + // Past stay (retrieved) + current stay (stored). + TireStorage::create(['tire_set_id' => $set->id, 'location' => 'A1', 'status' => 'retrieved', 'checked_in_at' => now()->subYear(), 'checked_out_at' => now()->subMonths(6)]); + TireStorage::create(['tire_set_id' => $set->id, 'location' => 'A2', 'status' => 'stored', 'checked_in_at' => now()]); + + $this->assertEquals(2, $set->storage()->count()); + $this->assertEquals('A2', $set->currentStorage()->location); + } + + public function test_tire_sets_isolated_per_tenant(): void + { + $this->makeSet(); + $other = $this->makeCompany('othertires'); + app(TenantManager::class)->setCurrent($other); + $this->assertEquals(0, TireSet::count()); + } + + private function makeCompany(string $slug): Company + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create(['plan_id' => $plan->id, 'slug' => $slug, 'name' => ucfirst($slug), 'status' => 'active']); + app(TenantManager::class)->setCurrent($company); + return $company; + } + + private function makeSet(array $attrs = []): TireSet + { + $client = Client::create(['name' => 'C', 'phone' => '+3736' . random_int(1000000, 9999999), 'type' => 'individual', 'status' => 'active']); + return TireSet::create(array_merge([ + 'client_id' => $client->id, + 'season' => 'winter', + 'brand' => 'Michelin', + ], $attrs)); + } +}