Faza 3.2: Service modules — Norme-ore, Tehnicieni, Fișe lucru

Schema:
- users + specialization, color, hourly_rate (pentru maistri)
- labors: catalog manopere standard cu category/ore/preț (RO+RU)
- work_orders: nr unique per tenant, status workflow (9 stări),
  pay_status (3 stări), client/vehicle/master/deal/appointment refs,
  complaint/diagnosis/recommendations, total auto-calculat
- wo_works: manopere per fișă, recalc auto la save/delete
- wo_parts: piese per fișă (free-text deocamdată), discount/total auto

Filament resources (group Service):
- LaborResource: CRUD + grupare pe categorie + filter active
- WorkOrderResource: form complex în 4 secțiuni (antet, diagnostic, plată)
  + 2 RelationManagers (Works, Parts)
- MasterResource: vedere User filtrată role=mechanic, edit specializare/
  culoare calendar/tarif oră

Conversie auto: la adaugare manoperă din catalog Labor,
form populează numele + ore + preț/oră derivat (price/hours).

Number generator pentru WO: format WO-{YY}-{NNNN} per tenant per an,
calculat în CreateWorkOrder via WorkOrder::generateNumber().

Seed extins:
- 3 mecanici (Vasile/Andrei/Nicolae) cu culori + specializări
- 10 manopere standard din prototipul AutoCRM.html
- 1 fișă demo (BMW X5 plăcuțe Brembo) cu 1 manoperă + 1 piesă, total auto
This commit is contained in:
2026-05-06 21:24:07 +00:00
parent c17fb2b413
commit 51a0bab39e
24 changed files with 1112 additions and 172 deletions
@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Tenant\Resources\WorkOrderResource\Pages;
use App\Filament\Tenant\Resources\WorkOrderResource;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Filament\Resources\Pages\CreateRecord;
class CreateWorkOrder extends CreateRecord
{
protected static string $resource = WorkOrderResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$companyId = app(TenantManager::class)->currentId();
$data['number'] = WorkOrder::generateNumber($companyId);
return $data;
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\WorkOrderResource\Pages;
use App\Filament\Tenant\Resources\WorkOrderResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditWorkOrder extends EditRecord
{
protected static string $resource = WorkOrderResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\WorkOrderResource\Pages;
use App\Filament\Tenant\Resources\WorkOrderResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListWorkOrders extends ListRecords
{
protected static string $resource = WorkOrderResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
use App\Models\Tenant\WorkOrderPart;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class PartsRelationManager extends RelationManager
{
protected static string $relationship = 'parts';
protected static ?string $title = 'Piese';
public function form(Schema $schema): Schema
{
return $schema->components([
Forms\Components\TextInput::make('name')->label('Denumire')->required()->columnSpanFull(),
Forms\Components\TextInput::make('article')->label('Cod articol')->maxLength(64),
Forms\Components\TextInput::make('brand')->label('Brand')->maxLength(64),
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)->required(),
Forms\Components\TextInput::make('unit')->label('UM')->maxLength(16)->default('buc'),
Forms\Components\TextInput::make('buy_price')->label('Preț achiziție')->numeric()->default(0),
Forms\Components\TextInput::make('sell_price')->label('Preț vânzare')->numeric()->required(),
Forms\Components\TextInput::make('discount_pct')->label('Discount %')->numeric()->default(0),
Forms\Components\Select::make('status')
->options(WorkOrderPart::STATUSES)
->default('needed')
->required(),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name')->label('Piesă')->wrap(),
Tables\Columns\TextColumn::make('article')->label('Cod')->placeholder('—'),
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
Tables\Columns\TextColumn::make('qty')->label('Cant.')->alignRight(),
Tables\Columns\TextColumn::make('sell_price')->label('Preț')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('status')
->formatStateUsing(fn ($s) => WorkOrderPart::STATUSES[$s] ?? $s)
->badge()
->colors([
'gray' => ['needed'],
'warning' => ['ordered'],
'info' => ['delivered'],
'success' => ['installed'],
]),
])
->headerActions([
Actions\CreateAction::make(),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
]);
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
use App\Models\Tenant\Labor;
use App\Models\Tenant\User;
use App\Models\Tenant\WorkOrderWork;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class WorksRelationManager extends RelationManager
{
protected static string $relationship = 'works';
protected static ?string $title = 'Manopere';
public function form(Schema $schema): Schema
{
return $schema->components([
Forms\Components\Select::make('labor_id')
->label('Catalog manoperă')
->options(fn () => Labor::where('is_active', true)
->get()
->mapWithKeys(fn ($l) => [$l->id => "[{$l->category}] {$l->name_ro} ({$l->hours}h)"])
->toArray())
->searchable()
->live()
->afterStateUpdated(function ($state, Set $set) {
if ($state && $labor = Labor::find($state)) {
$set('name', $labor->name_ro);
$set('hours', $labor->hours);
$set('price_per_hour', $labor->hours > 0 ? round($labor->price / max((float) $labor->hours, 1), 2) : 0);
}
})
->columnSpanFull(),
Forms\Components\TextInput::make('name')->label('Nume')->required()->columnSpanFull(),
Forms\Components\TextInput::make('hours')->label('Ore')->numeric()->default(1)->required(),
Forms\Components\TextInput::make('price_per_hour')->label('Preț/h')->numeric()->required(),
Forms\Components\Select::make('master_id')
->label('Maistru')
->options(fn () => User::pluck('name', 'id'))
->searchable(),
Forms\Components\Select::make('status')
->options(WorkOrderWork::STATUSES)
->default('todo')
->required(),
Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(2),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name')->label('Manoperă')->wrap(),
Tables\Columns\TextColumn::make('hours')->label('Ore')->numeric(decimalPlaces: 2)->alignRight(),
Tables\Columns\TextColumn::make('price_per_hour')->label('Preț/h')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('total')->label('Total')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('master.name')->label('Maistru')->placeholder('—'),
Tables\Columns\TextColumn::make('status')
->formatStateUsing(fn ($s) => WorkOrderWork::STATUSES[$s] ?? $s)
->badge()
->colors(['gray' => ['todo'], 'warning' => ['in_progress'], 'success' => ['done']]),
])
->headerActions([
Actions\CreateAction::make(),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
]);
}
}