Stage 10 — Bodyshop / PDR / Detailing: damage map + insurance + photos

Completes the 18-stage roadmap (17/18 fully functional, 18 partial).

Schema:
- bodyshop_jobs (type body_repair/pdr/painting/detailing/ceramic/ppf/polishing,
  status workflow, insurance case fields, estimate/approved amounts)
- damage_points (zone, kind, severity) — the damage map

Models:
- BodyshopJob (HasMedia: photos_before/photos_after), auto number BS-YY-NNNN
- DamagePoint with ZONES/KINDS/SEVERITIES

Filament (new "Tinichigerie" nav group):
- BodyshopJobResource: type/status, collapsible insurance section (conditional
  fields), before/after photo upload, estimate/approved amounts
- DamagePointsRelationManager (zone + kind + colour-coded severity)
- Table with type badge, insurance flag, damage count; nav badge = open jobs

Tests (5 new):
- auto number; damage points relation; insurance fields persist;
  detailing types supported; tenant isolation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 06:49:47 +00:00
parent e8078f157a
commit 5e255b7b40
9 changed files with 532 additions and 0 deletions
@@ -0,0 +1,145 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
use App\Filament\Tenant\Resources\BodyshopJobResource\RelationManagers;
use App\Models\Tenant\BodyshopJob;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class BodyshopJobResource extends Resource
{
protected static ?string $model = BodyshopJob::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-paint-brush';
protected static ?string $navigationLabel = 'Tinichigerie / Detailing';
protected static string|\UnitEnum|null $navigationGroup = 'Tinichigerie';
protected static ?string $modelLabel = 'lucrare caroserie';
protected static ?string $pluralModelLabel = 'lucrări caroserie';
protected static ?int $navigationSort = 80;
public static function getNavigationBadge(): ?string
{
$open = static::getModel()::query()
->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'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
use App\Filament\Tenant\Resources\BodyshopJobResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBodyshopJob extends CreateRecord
{
protected static string $resource = BodyshopJobResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
use App\Filament\Tenant\Resources\BodyshopJobResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditBodyshopJob extends EditRecord
{
protected static string $resource = BodyshopJobResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
use App\Filament\Tenant\Resources\BodyshopJobResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListBodyshopJobs extends ListRecords
{
protected static string $resource = BodyshopJobResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Tenant\Resources\BodyshopJobResource\RelationManagers;
use App\Models\Tenant\DamagePoint;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class DamagePointsRelationManager extends RelationManager
{
protected static string $relationship = 'damagePoints';
protected static ?string $title = 'Hartă daune';
public function form(Schema $schema): Schema
{
return $schema->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.');
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
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 BodyshopJob extends Model implements HasMedia
{
use BelongsToTenant, InteractsWithMedia, SoftDeletes;
public const TYPES = [
'body_repair' => '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()
);
}
});
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DamagePoint extends Model
{
use BelongsToTenant;
public const ZONES = [
'Bară față', 'Capotă', 'Aripă FS', 'Aripă FD',
'Ușă FS', 'Ușă FD', 'Ușă SS', 'Ușă SD',
'Aripă SS', 'Aripă SD', 'Bară spate', 'Portbagaj',
'Plafon', 'Parbriz', 'Lunetă', 'Prag S', 'Prag D',
'Oglindă S', 'Oglindă D', 'Jantă',
];
public const KINDS = [
'Zgârietură', 'Lovitură', 'Fisură', 'Rugină', 'Vopsea sărită', 'Spart',
];
public const SEVERITIES = [
'minor' => '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');
}
}
@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bodyshop_jobs', function (Blueprint $t) {
$t->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');
}
};
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\BodyshopJob;
use App\Models\Tenant\Client;
use App\Models\Tenant\DamagePoint;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BodyshopTest extends TestCase
{
use RefreshDatabase;
private Company $company;
protected function setUp(): void
{
parent::setUp();
$this->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;
}
}