diff --git a/app/Filament/Tenant/Resources/BodyshopJobResource.php b/app/Filament/Tenant/Resources/BodyshopJobResource.php new file mode 100644 index 0000000..ec26e8f --- /dev/null +++ b/app/Filament/Tenant/Resources/BodyshopJobResource.php @@ -0,0 +1,145 @@ +whereNotIn('status', ['delivered', 'cancelled'])->count(); + return $open > 0 ? (string) $open : null; + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Schemas\Components\Section::make('Lucrare') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false)->placeholder('Generat automat'), + Forms\Components\Select::make('type')->label('Tip')->options(BodyshopJob::TYPES)->default('body_repair')->required(), + Forms\Components\Select::make('status')->label('Status')->options(BodyshopJob::STATUSES)->default('estimate')->required(), + Forms\Components\Select::make('client_id') + ->label('Client') + ->options(fn () => Client::pluck('name', 'id')) + ->searchable()->live(), + 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('estimate_amount')->label('Deviz')->numeric()->default(0), + Forms\Components\TextInput::make('approved_amount')->label('Aprobat')->numeric()->default(0), + ]), + Schemas\Components\Section::make('Asigurare') + ->collapsible() + ->columns(3) + ->schema([ + Forms\Components\Toggle::make('is_insurance')->label('Caz de asigurare')->live()->columnSpanFull(), + Forms\Components\TextInput::make('insurer')->label('Asigurător') + ->visible(fn (Get $get) => $get('is_insurance')), + Forms\Components\TextInput::make('policy_no')->label('Nr. poliță') + ->visible(fn (Get $get) => $get('is_insurance')), + Forms\Components\TextInput::make('claim_no')->label('Nr. dosar daună') + ->visible(fn (Get $get) => $get('is_insurance')), + Forms\Components\Select::make('insurance_status')->label('Status dosar') + ->options(BodyshopJob::INSURANCE_STATUSES) + ->visible(fn (Get $get) => $get('is_insurance')), + ]), + Schemas\Components\Section::make('Foto înainte / după') + ->columns(2) + ->schema([ + \Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos_before') + ->label('Înainte')->collection('photos_before')->multiple()->image()->reorderable()->maxFiles(20), + \Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos_after') + ->label('După')->collection('photos_after')->multiple()->image()->reorderable()->maxFiles(20), + ]), + Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(), + Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable()->placeholder('—'), + Tables\Columns\TextColumn::make('vehicle.plate')->label('Auto')->placeholder('—'), + Tables\Columns\TextColumn::make('type') + ->formatStateUsing(fn ($s) => BodyshopJob::TYPES[$s] ?? $s) + ->badge()->color('info'), + Tables\Columns\IconColumn::make('is_insurance')->label('Asig.')->boolean()->toggleable(), + Tables\Columns\TextColumn::make('damage_points_count')->counts('damagePoints')->label('Daune')->alignRight(), + Tables\Columns\TextColumn::make('approved_amount')->label('Aprobat')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($s) => BodyshopJob::STATUSES[$s] ?? $s) + ->badge() + ->colors([ + 'gray' => ['estimate'], + 'info' => ['approved', 'in_progress'], + 'success' => ['done', 'delivered'], + 'danger' => ['cancelled'], + ]), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('type')->options(BodyshopJob::TYPES), + Tables\Filters\SelectFilter::make('status')->options(BodyshopJob::STATUSES), + Tables\Filters\TernaryFilter::make('is_insurance')->label('Caz asigurare'), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Nicio lucrare de caroserie') + ->emptyStateDescription('Înregistrează lucrări de tinichigerie, vopsitorie, PDR, detailing, ceramică, PPF sau polish. Hartă daune, dosar asigurare și arhivă foto înainte/după.') + ->emptyStateIcon('heroicon-o-paint-brush') + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\DamagePointsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBodyshopJobs::route('/'), + 'create' => Pages\CreateBodyshopJob::route('/create'), + 'edit' => Pages\EditBodyshopJob::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/BodyshopJobResource/Pages/CreateBodyshopJob.php b/app/Filament/Tenant/Resources/BodyshopJobResource/Pages/CreateBodyshopJob.php new file mode 100644 index 0000000..1b8c063 --- /dev/null +++ b/app/Filament/Tenant/Resources/BodyshopJobResource/Pages/CreateBodyshopJob.php @@ -0,0 +1,11 @@ +components([ + Forms\Components\Select::make('zone') + ->label('Zonă') + ->options(array_combine(DamagePoint::ZONES, DamagePoint::ZONES)) + ->searchable() + ->required(), + Forms\Components\Select::make('kind') + ->label('Tip daună') + ->options(array_combine(DamagePoint::KINDS, DamagePoint::KINDS)) + ->required(), + Forms\Components\Select::make('severity') + ->label('Gravitate') + ->options(DamagePoint::SEVERITIES) + ->default('minor') + ->required(), + Forms\Components\Textarea::make('notes')->label('Observații')->rows(2)->columnSpanFull(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('zone') + ->columns([ + Tables\Columns\TextColumn::make('zone')->label('Zonă')->badge()->color('gray'), + Tables\Columns\TextColumn::make('kind')->label('Tip'), + Tables\Columns\TextColumn::make('severity') + ->label('Gravitate') + ->formatStateUsing(fn ($s) => DamagePoint::SEVERITIES[$s] ?? $s) + ->badge() + ->colors(['gray' => ['minor'], 'warning' => ['medium'], 'danger' => ['severe']]), + Tables\Columns\TextColumn::make('notes')->limit(40)->placeholder('—'), + ]) + ->headerActions([Actions\CreateAction::make()]) + ->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]) + ->emptyStateHeading('Nicio daună marcată') + ->emptyStateDescription('Adaugă punctele de daună pe zone (capotă, ușă, aripă) cu tip și gravitate — formează harta de daune a mașinii.'); + } +} diff --git a/app/Models/Tenant/BodyshopJob.php b/app/Models/Tenant/BodyshopJob.php new file mode 100644 index 0000000..330c9c6 --- /dev/null +++ b/app/Models/Tenant/BodyshopJob.php @@ -0,0 +1,102 @@ + 'Tinichigerie', + 'pdr' => 'PDR (fără vopsire)', + 'painting' => 'Vopsitorie', + 'detailing' => 'Detailing', + 'ceramic' => 'Ceramică', + 'ppf' => 'Folie PPF', + 'polishing' => 'Polish', + ]; + + public const STATUSES = [ + 'estimate' => 'Deviz', + 'approved' => 'Aprobat', + 'in_progress' => 'În lucru', + 'done' => 'Finalizat', + 'delivered' => 'Predat', + 'cancelled' => 'Anulat', + ]; + + public const INSURANCE_STATUSES = [ + 'submitted' => 'Depus', + 'approved' => 'Aprobat', + 'rejected' => 'Respins', + 'paid' => 'Plătit', + ]; + + protected $fillable = [ + 'company_id', 'work_order_id', 'client_id', 'vehicle_id', + 'number', 'type', 'status', + 'is_insurance', 'insurer', 'policy_no', 'claim_no', 'insurance_status', + 'estimate_amount', 'approved_amount', 'notes', + ]; + + protected $casts = [ + 'is_insurance' => 'boolean', + 'estimate_amount' => 'decimal:2', + 'approved_amount' => 'decimal:2', + ]; + + public function registerMediaCollections(): void + { + $this->addMediaCollection('photos_before'); + $this->addMediaCollection('photos_after'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function damagePoints(): HasMany + { + return $this->hasMany(DamagePoint::class); + } + + public static function generateNumber(int $companyId): string + { + $year = date('y'); + $count = static::withoutGlobalScopes() + ->where('company_id', $companyId) + ->whereYear('created_at', date('Y')) + ->count(); + return sprintf('BS-%s-%04d', $year, $count + 1); + } + + protected static function booted(): void + { + static::creating(function (self $job) { + if (empty($job->number)) { + $job->number = static::generateNumber( + $job->company_id ?: app(\App\Tenancy\TenantManager::class)->currentId() + ); + } + }); + } +} diff --git a/app/Models/Tenant/DamagePoint.php b/app/Models/Tenant/DamagePoint.php new file mode 100644 index 0000000..aea35a4 --- /dev/null +++ b/app/Models/Tenant/DamagePoint.php @@ -0,0 +1,39 @@ + 'Minoră', + 'medium' => 'Medie', + 'severe' => 'Gravă', + ]; + + protected $fillable = [ + 'company_id', 'bodyshop_job_id', 'zone', 'kind', 'severity', 'notes', + ]; + + public function job(): BelongsTo + { + return $this->belongsTo(BodyshopJob::class, 'bodyshop_job_id'); + } +} diff --git a/database/migrations/2026_05_28_230000_create_bodyshop_tables.php b/database/migrations/2026_05_28_230000_create_bodyshop_tables.php new file mode 100644 index 0000000..9064ef2 --- /dev/null +++ b/database/migrations/2026_05_28_230000_create_bodyshop_tables.php @@ -0,0 +1,60 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('work_order_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('vehicle_id')->nullable()->constrained()->nullOnDelete(); + + $t->string('number', 32); + $t->string('type', 24)->default('body_repair'); // body_repair/pdr/painting/detailing/ceramic/ppf/polishing + $t->string('status', 16)->default('estimate'); // estimate/approved/in_progress/done/delivered/cancelled + + // Insurance case + $t->boolean('is_insurance')->default(false); + $t->string('insurer')->nullable(); + $t->string('policy_no', 64)->nullable(); + $t->string('claim_no', 64)->nullable(); + $t->string('insurance_status', 24)->nullable(); // submitted/approved/rejected/paid + + $t->decimal('estimate_amount', 12, 2)->default(0); + $t->decimal('approved_amount', 12, 2)->default(0); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->unique(['company_id', 'number']); + $t->index(['company_id', 'status']); + $t->index(['company_id', 'type']); + }); + + Schema::create('damage_points', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('bodyshop_job_id')->constrained()->cascadeOnDelete(); + $t->string('zone', 40); // Capotă / Aripă FS / Ușă SD / ... + $t->string('kind', 32); // Zgârietură / Lovitură / Fisură / Rugină / ... + $t->string('severity', 12)->default('minor'); // minor/medium/severe + $t->text('notes')->nullable(); + $t->timestamps(); + + $t->index(['company_id', 'bodyshop_job_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('damage_points'); + Schema::dropIfExists('bodyshop_jobs'); + } +}; diff --git a/tests/Feature/BodyshopTest.php b/tests/Feature/BodyshopTest.php new file mode 100644 index 0000000..6ead3e6 --- /dev/null +++ b/tests/Feature/BodyshopTest.php @@ -0,0 +1,82 @@ +company = $this->makeCompany('body'); + } + + public function test_number_auto_generated(): void + { + $job = BodyshopJob::create(['type' => 'painting', 'status' => 'estimate']); + $this->assertStringStartsWith('BS-', $job->number); + } + + public function test_damage_points_relation(): void + { + $job = BodyshopJob::create(['type' => 'body_repair', 'status' => 'estimate']); + DamagePoint::create(['bodyshop_job_id' => $job->id, 'zone' => 'Capotă', 'kind' => 'Lovitură', 'severity' => 'severe']); + DamagePoint::create(['bodyshop_job_id' => $job->id, 'zone' => 'Ușă FS', 'kind' => 'Zgârietură', 'severity' => 'minor']); + + $this->assertEquals(2, $job->damagePoints()->count()); + $this->assertEquals('severe', $job->damagePoints()->where('zone', 'Capotă')->first()->severity); + } + + public function test_insurance_fields_persist(): void + { + $job = BodyshopJob::create([ + 'type' => 'body_repair', 'status' => 'approved', + 'is_insurance' => true, 'insurer' => 'MoldAsig', + 'policy_no' => 'POL-123', 'claim_no' => 'CLM-999', + 'insurance_status' => 'submitted', + 'estimate_amount' => 12000, 'approved_amount' => 11500, + ]); + + $fresh = $job->fresh(); + $this->assertTrue($fresh->is_insurance); + $this->assertEquals('MoldAsig', $fresh->insurer); + $this->assertEquals('CLM-999', $fresh->claim_no); + $this->assertEquals(11500.0, (float) $fresh->approved_amount); + } + + public function test_detailing_types_supported(): void + { + foreach (['ceramic', 'ppf', 'polishing', 'detailing', 'pdr'] as $type) { + $job = BodyshopJob::create(['type' => $type, 'status' => 'estimate']); + $this->assertArrayHasKey($job->type, BodyshopJob::TYPES); + } + } + + public function test_bodyshop_jobs_isolated_per_tenant(): void + { + BodyshopJob::create(['type' => 'painting', 'status' => 'estimate']); + $other = $this->makeCompany('otherbody'); + app(TenantManager::class)->setCurrent($other); + $this->assertEquals(0, BodyshopJob::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; + } +}