diff --git a/app/Filament/Tenant/Resources/SubcontractJobResource.php b/app/Filament/Tenant/Resources/SubcontractJobResource.php new file mode 100644 index 0000000..eb9f253 --- /dev/null +++ b/app/Filament/Tenant/Resources/SubcontractJobResource.php @@ -0,0 +1,133 @@ +whereNotIn('status', ['done', 'returned', 'cancelled'])->count(); + return $open > 0 ? (string) $open : null; + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Schemas\Components\Section::make('Lucrare') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false)->placeholder('Generat automat'), + Forms\Components\Select::make('status')->options(SubcontractJob::STATUSES)->default('sent')->required(), + Forms\Components\Select::make('subcontractor_id') + ->label('Subcontractor') + ->options(fn () => Subcontractor::where('is_active', true)->pluck('name', 'id')) + ->searchable(), + Forms\Components\Select::make('work_order_id') + ->label('Fișă asociată') + ->options(fn () => WorkOrder::whereNotIn('status', ['done', 'cancelled']) + ->get()->mapWithKeys(fn ($w) => [$w->id => "#{$w->number} · " . ($w->vehicle?->plate ?? '')])->toArray()) + ->searchable(), + Forms\Components\Select::make('category') + ->label('Categorie') + ->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES)) + ->searchable(), + Forms\Components\Textarea::make('description')->label('Descriere')->rows(2)->columnSpanFull(), + ]), + Schemas\Components\Section::make('Cost & marjă') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('cost')->label('Cost (de la terț)')->numeric()->default(0)->required(), + Forms\Components\TextInput::make('markup_pct')->label('Markup %')->numeric()->default(0) + ->helperText('> 0 calculează automat prețul client.'), + Forms\Components\TextInput::make('client_price')->label('Preț client')->numeric()->default(0) + ->helperText('Setat manual dacă markup = 0.'), + Forms\Components\Toggle::make('paid_to_sub')->label('Plătit către terț'), + ]), + Schemas\Components\Section::make('Termene') + ->columns(3) + ->schema([ + Forms\Components\DatePicker::make('sent_at')->label('Trimis')->default(today()), + Forms\Components\DatePicker::make('eta')->label('ETA'), + Forms\Components\DatePicker::make('returned_at')->label('Returnat'), + 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('subcontractor.name')->label('Terț')->placeholder('—'), + Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'), + Tables\Columns\TextColumn::make('workOrder.number')->label('Fișă')->placeholder('—'), + Tables\Columns\TextColumn::make('cost')->label('Cost')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('client_price')->label('Preț client')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('margin') + ->label('Marjă') + ->state(fn (SubcontractJob $r) => $r->margin()) + ->money('MDL') + ->alignRight() + ->color(fn ($state) => (float) $state > 0 ? 'success' : ((float) $state < 0 ? 'danger' : 'gray')), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($s) => SubcontractJob::STATUSES[$s] ?? $s) + ->badge() + ->colors([ + 'warning' => ['sent', 'in_progress'], + 'success' => ['done', 'returned'], + 'danger' => ['cancelled'], + ]), + Tables\Columns\IconColumn::make('paid_to_sub')->label('Plătit terț')->boolean()->toggleable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status')->options(SubcontractJob::STATUSES), + Tables\Filters\SelectFilter::make('subcontractor_id') + ->label('Subcontractor') + ->options(fn () => Subcontractor::pluck('name', 'id')), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Nicio lucrare la terți') + ->emptyStateDescription('Înregistrează lucrările trimise la ateliere externe (turbo, cutii, vopsitorie). Costul terțului + markup intră automat în totalul fișei asociate.') + ->emptyStateIcon('heroicon-o-arrow-top-right-on-square') + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListSubcontractJobs::route('/'), + 'create' => Pages\CreateSubcontractJob::route('/create'), + 'edit' => Pages\EditSubcontractJob::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/SubcontractJobResource/Pages/CreateSubcontractJob.php b/app/Filament/Tenant/Resources/SubcontractJobResource/Pages/CreateSubcontractJob.php new file mode 100644 index 0000000..2d9c3c2 --- /dev/null +++ b/app/Filament/Tenant/Resources/SubcontractJobResource/Pages/CreateSubcontractJob.php @@ -0,0 +1,11 @@ +components([ + Schemas\Components\Section::make()->columns(2)->schema([ + Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(160), + Forms\Components\Select::make('specialty') + ->label('Specialitate') + ->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES)) + ->searchable(), + Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->maxLength(40), + Forms\Components\TextInput::make('email')->email()->maxLength(120), + Forms\Components\Select::make('rating') + ->label('Rating') + ->options([1 => '★', 2 => '★★', 3 => '★★★', 4 => '★★★★', 5 => '★★★★★']) + ->default(3), + Forms\Components\Toggle::make('is_active')->label('Activ')->default(true), + 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('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('specialty')->badge()->placeholder('—'), + Tables\Columns\TextColumn::make('phone')->copyable()->placeholder('—'), + Tables\Columns\TextColumn::make('rating')->formatStateUsing(fn ($s) => str_repeat('★', (int) $s)), + Tables\Columns\TextColumn::make('jobs_count')->counts('jobs')->label('Lucrări')->alignRight(), + Tables\Columns\IconColumn::make('is_active')->boolean(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_active')->label('Activi'), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Niciun subcontractor') + ->emptyStateDescription('Adaugă atelierele terțe la care trimiți lucrări (turbo, cutii, vopsitorie, PDR) și urmărește costul + marja.') + ->emptyStateIcon('heroicon-o-user-group') + ->defaultSort('name'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListSubcontractors::route('/'), + 'create' => Pages\CreateSubcontractor::route('/create'), + 'edit' => Pages\EditSubcontractor::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/SubcontractorResource/Pages/CreateSubcontractor.php b/app/Filament/Tenant/Resources/SubcontractorResource/Pages/CreateSubcontractor.php new file mode 100644 index 0000000..3a5cbcc --- /dev/null +++ b/app/Filament/Tenant/Resources/SubcontractorResource/Pages/CreateSubcontractor.php @@ -0,0 +1,11 @@ +components([ + Forms\Components\Select::make('subcontractor_id') + ->label('Subcontractor') + ->options(fn () => Subcontractor::where('is_active', true)->pluck('name', 'id')) + ->searchable() + ->columnSpanFull(), + Forms\Components\Select::make('category') + ->label('Categorie') + ->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES)) + ->searchable(), + Forms\Components\Select::make('status')->options(SubcontractJob::STATUSES)->default('sent')->required(), + Forms\Components\Textarea::make('description')->label('Descriere')->rows(2)->columnSpanFull(), + Forms\Components\TextInput::make('cost')->label('Cost (terț)')->numeric()->default(0)->required(), + Forms\Components\TextInput::make('markup_pct')->label('Markup %')->numeric()->default(0), + Forms\Components\TextInput::make('client_price')->label('Preț client')->numeric()->default(0) + ->helperText('Folosit dacă markup = 0.'), + Forms\Components\DatePicker::make('eta')->label('ETA'), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('number') + ->columns([ + Tables\Columns\TextColumn::make('number')->label('Nr.'), + Tables\Columns\TextColumn::make('subcontractor.name')->label('Terț')->placeholder('—'), + Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'), + Tables\Columns\TextColumn::make('cost')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('client_price')->label('Preț client')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('margin') + ->label('Marjă') + ->state(fn (SubcontractJob $r) => $r->margin()) + ->money('MDL')->alignRight() + ->color(fn ($s) => (float) $s > 0 ? 'success' : ((float) $s < 0 ? 'danger' : 'gray')), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($s) => SubcontractJob::STATUSES[$s] ?? $s) + ->badge() + ->colors(['warning' => ['sent', 'in_progress'], 'success' => ['done', 'returned'], 'danger' => ['cancelled']]), + ]) + ->headerActions([Actions\CreateAction::make()]) + ->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]); + } +} diff --git a/app/Models/Tenant/SubcontractJob.php b/app/Models/Tenant/SubcontractJob.php new file mode 100644 index 0000000..12aac1a --- /dev/null +++ b/app/Models/Tenant/SubcontractJob.php @@ -0,0 +1,85 @@ + 'Trimis', + 'in_progress' => 'În lucru', + 'done' => 'Gata', + 'returned' => 'Returnat', + 'cancelled' => 'Anulat', + ]; + + protected $fillable = [ + 'company_id', 'work_order_id', 'subcontractor_id', + 'number', 'category', 'description', + 'cost', 'markup_pct', 'client_price', + 'status', 'sent_at', 'eta', 'returned_at', 'paid_to_sub', 'notes', + ]; + + protected $casts = [ + 'cost' => 'decimal:2', + 'markup_pct' => 'decimal:2', + 'client_price' => 'decimal:2', + 'sent_at' => 'date', + 'eta' => 'date', + 'returned_at' => 'date', + 'paid_to_sub' => 'boolean', + ]; + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function subcontractor(): BelongsTo + { + return $this->belongsTo(Subcontractor::class); + } + + /** Our margin = what we bill the client − what the sub charges us. */ + public function margin(): float + { + return round((float) $this->client_price - (float) $this->cost, 2); + } + + 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('SC-%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() + ); + } + }); + + static::saving(function (self $job) { + // markup drives client_price unless markup is zero (then keep manual price). + if ((float) $job->markup_pct > 0) { + $job->client_price = round((float) $job->cost * (1 + (float) $job->markup_pct / 100), 2); + } + }); + + static::saved(fn (self $job) => $job->workOrder?->recalcTotal()); + static::deleted(fn (self $job) => $job->workOrder?->recalcTotal()); + } +} diff --git a/app/Models/Tenant/Subcontractor.php b/app/Models/Tenant/Subcontractor.php new file mode 100644 index 0000000..94cde16 --- /dev/null +++ b/app/Models/Tenant/Subcontractor.php @@ -0,0 +1,32 @@ + 'boolean', + ]; + + public function jobs(): HasMany + { + return $this->hasMany(SubcontractJob::class); + } +} diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index 5669001..d13c4ab 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -93,6 +93,11 @@ class WorkOrder extends Model implements HasMedia return $this->hasMany(Payment::class); } + public function subcontractJobs(): HasMany + { + return $this->hasMany(SubcontractJob::class); + } + public function paidAmount(): float { return (float) $this->payments()->sum('amount'); @@ -107,7 +112,10 @@ class WorkOrder extends Model implements HasMedia { $worksTotal = $this->works()->sum('total'); $partsTotal = $this->parts()->sum('total'); - $sub = (float) $worksTotal + (float) $partsTotal; + $subcontractTotal = $this->subcontractJobs() + ->where('status', '!=', 'cancelled') + ->sum('client_price'); + $sub = (float) $worksTotal + (float) $partsTotal + (float) $subcontractTotal; $disc = (float) $this->discount_pct; $this->total = round($sub * (1 - $disc / 100), 2); $this->save(); diff --git a/database/migrations/2026_05_28_210000_create_subcontractor_tables.php b/database/migrations/2026_05_28_210000_create_subcontractor_tables.php new file mode 100644 index 0000000..b63890f --- /dev/null +++ b/database/migrations/2026_05_28_210000_create_subcontractor_tables.php @@ -0,0 +1,62 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('name'); + $t->string('specialty', 48)->nullable(); // Turbo / Cutie viteze / ... + $t->string('phone', 40)->nullable(); + $t->string('email')->nullable(); + $t->unsignedTinyInteger('rating')->default(3); + $t->boolean('is_active')->default(true); + $t->text('notes')->nullable(); + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'is_active']); + }); + + Schema::create('subcontract_jobs', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('work_order_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('subcontractor_id')->nullable()->constrained()->nullOnDelete(); + + $t->string('number', 32); + $t->string('category', 48)->nullable(); + $t->text('description')->nullable(); + + $t->decimal('cost', 12, 2)->default(0); // what the sub charges us + $t->decimal('markup_pct', 5, 2)->default(0); + $t->decimal('client_price', 12, 2)->default(0); // what we bill the client + + $t->string('status', 16)->default('sent'); // sent / in_progress / done / returned / cancelled + $t->date('sent_at')->nullable(); + $t->date('eta')->nullable(); + $t->date('returned_at')->nullable(); + $t->boolean('paid_to_sub')->default(false); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->unique(['company_id', 'number']); + $t->index(['company_id', 'status']); + $t->index(['company_id', 'work_order_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('subcontract_jobs'); + Schema::dropIfExists('subcontractors'); + } +}; diff --git a/tests/Feature/SubcontractorTest.php b/tests/Feature/SubcontractorTest.php new file mode 100644 index 0000000..96f6664 --- /dev/null +++ b/tests/Feature/SubcontractorTest.php @@ -0,0 +1,119 @@ +company = $this->makeCompany('subs'); + } + + public function test_client_price_computed_from_cost_and_markup(): void + { + $job = SubcontractJob::create([ + 'category' => 'Turbo', 'cost' => 1000, 'markup_pct' => 25, 'status' => 'sent', + ]); + $this->assertEquals(1250.0, (float) $job->client_price); + $this->assertEquals(250.0, $job->margin()); + } + + public function test_manual_client_price_when_no_markup(): void + { + $job = SubcontractJob::create([ + 'category' => 'Vopsitorie', 'cost' => 800, 'markup_pct' => 0, 'client_price' => 1100, 'status' => 'sent', + ]); + $this->assertEquals(1100.0, (float) $job->client_price); + $this->assertEquals(300.0, $job->margin()); + } + + public function test_number_auto_generated(): void + { + $job = SubcontractJob::create(['cost' => 100, 'markup_pct' => 10, 'status' => 'sent']); + $this->assertStringStartsWith('SC-', $job->number); + } + + public function test_wo_total_includes_subcontract_jobs(): void + { + $wo = $this->makeWorkOrder(); + SubcontractJob::create([ + 'work_order_id' => $wo->id, 'category' => 'Cutie viteze', + 'cost' => 2000, 'markup_pct' => 20, 'status' => 'sent', + ]); + + $wo->refresh(); + // client_price = 2000 × 1.20 = 2400 → WO total = 2400 + $this->assertEquals(2400.0, (float) $wo->total); + } + + public function test_cancelled_job_excluded_from_total(): void + { + $wo = $this->makeWorkOrder(); + $job = SubcontractJob::create([ + 'work_order_id' => $wo->id, 'cost' => 1000, 'markup_pct' => 50, 'status' => 'sent', + ]); + $wo->refresh(); + $this->assertEquals(1500.0, (float) $wo->total); + + $job->update(['status' => 'cancelled']); + $wo->refresh(); + $this->assertEquals(0.0, (float) $wo->total); + } + + public function test_deleting_job_recalcs_wo(): void + { + $wo = $this->makeWorkOrder(); + $job = SubcontractJob::create([ + 'work_order_id' => $wo->id, 'cost' => 500, 'markup_pct' => 100, 'status' => 'sent', + ]); + $wo->refresh(); + $this->assertEquals(1000.0, (float) $wo->total); + + $job->delete(); + $wo->refresh(); + $this->assertEquals(0.0, (float) $wo->total); + } + + public function test_subcontractors_isolated_per_tenant(): void + { + Subcontractor::create(['name' => 'TurboFix', 'is_active' => true]); + $other = $this->makeCompany('othersubs'); + app(TenantManager::class)->setCurrent($other); + $this->assertEquals(0, Subcontractor::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 makeWorkOrder(): WorkOrder + { + $client = Client::create(['name' => 'C', 'phone' => '+3736' . random_int(1000000, 9999999), 'type' => 'individual', 'status' => 'active']); + $vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'P' . random_int(100, 999)]); + return WorkOrder::create([ + 'number' => WorkOrder::generateNumber($this->company->id), + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'opened_at' => now(), 'status' => 'in_work', + ]); + } +}