From 51a0bab39e88da2cf44b605cd0c7ea71afeccbe3 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Wed, 6 May 2026 21:24:07 +0000 Subject: [PATCH] =?UTF-8?q?Faza=203.2:=20Service=20modules=20=E2=80=94=20N?= =?UTF-8?q?orme-ore,=20Tehnicieni,=20Fi=C8=99e=20lucru?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Tenant/Resources/LaborResource.php | 84 +++++++++ .../LaborResource/Pages/CreateLabor.php | 14 ++ .../LaborResource/Pages/EditLabor.php | 14 ++ .../LaborResource/Pages/ListLabors.php | 14 ++ .../Tenant/Resources/MasterResource.php | 112 ++++++++++++ .../MasterResource/Pages/CreateMaster.php | 14 ++ .../MasterResource/Pages/EditMaster.php | 14 ++ .../MasterResource/Pages/ListMasters.php | 14 ++ .../Tenant/Resources/WorkOrderResource.php | 159 ++++++++++++++++ .../Pages/CreateWorkOrder.php | 20 ++ .../WorkOrderResource/Pages/EditWorkOrder.php | 17 ++ .../Pages/ListWorkOrders.php | 17 ++ .../RelationManagers/PartsRelationManager.php | 67 +++++++ .../RelationManagers/WorksRelationManager.php | 79 ++++++++ app/Models/Tenant/Labor.php | 28 +++ app/Models/Tenant/User.php | 1 + app/Models/Tenant/WorkOrder.php | 95 ++++++++++ app/Models/Tenant/WorkOrderPart.php | 52 ++++++ app/Models/Tenant/WorkOrderWork.php | 55 ++++++ ...5_06_180000_add_master_fields_to_users.php | 24 +++ .../2026_05_06_180001_create_labors_table.php | 37 ++++ ...05_06_180002_create_work_orders_tables.php | 96 ++++++++++ database/seeders/DatabaseSeeder.php | 85 +++++++++ routes/web.php | 172 ------------------ 24 files changed, 1112 insertions(+), 172 deletions(-) create mode 100644 app/Filament/Tenant/Resources/LaborResource.php create mode 100644 app/Filament/Tenant/Resources/LaborResource/Pages/CreateLabor.php create mode 100644 app/Filament/Tenant/Resources/LaborResource/Pages/EditLabor.php create mode 100644 app/Filament/Tenant/Resources/LaborResource/Pages/ListLabors.php create mode 100644 app/Filament/Tenant/Resources/MasterResource.php create mode 100644 app/Filament/Tenant/Resources/MasterResource/Pages/CreateMaster.php create mode 100644 app/Filament/Tenant/Resources/MasterResource/Pages/EditMaster.php create mode 100644 app/Filament/Tenant/Resources/MasterResource/Pages/ListMasters.php create mode 100644 app/Filament/Tenant/Resources/WorkOrderResource.php create mode 100644 app/Filament/Tenant/Resources/WorkOrderResource/Pages/CreateWorkOrder.php create mode 100644 app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php create mode 100644 app/Filament/Tenant/Resources/WorkOrderResource/Pages/ListWorkOrders.php create mode 100644 app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php create mode 100644 app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php create mode 100644 app/Models/Tenant/Labor.php create mode 100644 app/Models/Tenant/WorkOrder.php create mode 100644 app/Models/Tenant/WorkOrderPart.php create mode 100644 app/Models/Tenant/WorkOrderWork.php create mode 100644 database/migrations/2026_05_06_180000_add_master_fields_to_users.php create mode 100644 database/migrations/2026_05_06_180001_create_labors_table.php create mode 100644 database/migrations/2026_05_06_180002_create_work_orders_tables.php diff --git a/app/Filament/Tenant/Resources/LaborResource.php b/app/Filament/Tenant/Resources/LaborResource.php new file mode 100644 index 0000000..bdda374 --- /dev/null +++ b/app/Filament/Tenant/Resources/LaborResource.php @@ -0,0 +1,84 @@ +components([ + Schemas\Components\Section::make('Manoperă') + ->columns(2) + ->schema([ + Forms\Components\Select::make('category') + ->label('Categorie') + ->options(array_combine(Labor::CATEGORIES, Labor::CATEGORIES)) + ->required() + ->searchable(), + Forms\Components\TextInput::make('code')->label('Cod')->maxLength(32), + Forms\Components\TextInput::make('name_ro')->label('Nume (RO)')->required()->maxLength(160), + Forms\Components\TextInput::make('name_ru')->label('Nume (RU)')->maxLength(160), + Forms\Components\TextInput::make('hours')->label('Ore')->numeric()->default(1)->required(), + Forms\Components\TextInput::make('price')->label('Preț (MDL)')->numeric()->default(0), + 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('category')->label('Categorie')->badge()->sortable(), + Tables\Columns\TextColumn::make('name_ro')->label('Manoperă')->searchable()->sortable(), + Tables\Columns\TextColumn::make('hours')->label('Ore')->numeric(decimalPlaces: 2)->alignRight(), + Tables\Columns\TextColumn::make('price')->label('Preț')->money('MDL')->alignRight(), + Tables\Columns\IconColumn::make('is_active')->label('Activă')->boolean(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('category') + ->options(array_combine(Labor::CATEGORIES, Labor::CATEGORIES)), + Tables\Filters\TernaryFilter::make('is_active')->label('Doar active'), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('category') + ->defaultGroup('category'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListLabors::route('/'), + 'create' => Pages\CreateLabor::route('/create'), + 'edit' => Pages\EditLabor::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/LaborResource/Pages/CreateLabor.php b/app/Filament/Tenant/Resources/LaborResource/Pages/CreateLabor.php new file mode 100644 index 0000000..7a87906 --- /dev/null +++ b/app/Filament/Tenant/Resources/LaborResource/Pages/CreateLabor.php @@ -0,0 +1,14 @@ +where('role', 'mechanic'); + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Schemas\Components\Section::make('Date personale') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(120), + Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->maxLength(40), + Forms\Components\TextInput::make('email')->label('Email')->email()->maxLength(120), + Forms\Components\Select::make('status') + ->options(['active' => 'Activ', 'inactive' => 'Inactiv', 'blocked' => 'Blocat']) + ->default('active') + ->required(), + ]), + Schemas\Components\Section::make('Profesie') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('specialization') + ->label('Specializare') + ->placeholder('Motor / Frâne / Electrică ...') + ->maxLength(120), + Forms\Components\ColorPicker::make('color')->label('Culoare în calendar'), + Forms\Components\TextInput::make('hourly_rate')->label('Tarif/oră')->numeric(), + Forms\Components\Hidden::make('role')->default('mechanic'), + ]), + Schemas\Components\Section::make('Acces în aplicație (opțional)') + ->columns(1) + ->collapsed() + ->schema([ + Forms\Components\TextInput::make('password') + ->label('Parolă (lasă gol pentru a nu schimba)') + ->password() + ->minLength(6) + ->dehydrated(fn ($state) => filled($state)) + ->dehydrateStateUsing(fn ($state) => Hash::make($state)), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\ColorColumn::make('color')->label(''), + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('specialization')->label('Specializare')->placeholder('—'), + Tables\Columns\TextColumn::make('phone')->copyable()->placeholder('—'), + Tables\Columns\TextColumn::make('hourly_rate')->label('Tarif/h')->money('MDL')->alignRight()->placeholder('—'), + Tables\Columns\TextColumn::make('status') + ->badge() + ->colors([ + 'success' => ['active'], + 'warning' => ['inactive'], + 'danger' => ['blocked'], + ]), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('name'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListMasters::route('/'), + 'create' => Pages\CreateMaster::route('/create'), + 'edit' => Pages\EditMaster::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/MasterResource/Pages/CreateMaster.php b/app/Filament/Tenant/Resources/MasterResource/Pages/CreateMaster.php new file mode 100644 index 0000000..9540946 --- /dev/null +++ b/app/Filament/Tenant/Resources/MasterResource/Pages/CreateMaster.php @@ -0,0 +1,14 @@ +label('Nou tehnician')]; } +} diff --git a/app/Filament/Tenant/Resources/WorkOrderResource.php b/app/Filament/Tenant/Resources/WorkOrderResource.php new file mode 100644 index 0000000..78ad591 --- /dev/null +++ b/app/Filament/Tenant/Resources/WorkOrderResource.php @@ -0,0 +1,159 @@ +components([ + Schemas\Components\Section::make('Antet') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('number') + ->label('Nr.') + ->disabled() + ->dehydrated(false) + ->placeholder('Generat automat'), + Forms\Components\DatePicker::make('opened_at') + ->label('Deschis') + ->default(today()) + ->required(), + Forms\Components\Select::make('status') + ->options(WorkOrder::STATUSES) + ->default('new') + ->required(), + 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\Select::make('master_id') + ->label('Maistru') + ->options(fn () => User::where('status', 'active')->pluck('name', 'id')) + ->searchable(), + Forms\Components\TextInput::make('mileage_in')->label('Km la intrare')->numeric(), + Forms\Components\TextInput::make('mileage_out')->label('Km la ieșire')->numeric(), + ]), + Schemas\Components\Section::make('Diagnostic') + ->collapsible() + ->schema([ + Forms\Components\Textarea::make('complaint')->label('Plângere client')->rows(2)->columnSpanFull(), + Forms\Components\Textarea::make('diagnosis')->label('Diagnostic')->rows(3)->columnSpanFull(), + Forms\Components\Textarea::make('recommendations')->label('Recomandări')->rows(2)->columnSpanFull(), + ]), + Schemas\Components\Section::make('Plată & total') + ->columns(3) + ->schema([ + Forms\Components\Select::make('pay_status') + ->options(WorkOrder::PAY_STATUSES) + ->default('unpaid') + ->required(), + Forms\Components\TextInput::make('discount_pct')->label('Discount %')->numeric()->default(0), + Forms\Components\TextInput::make('total')->label('Total')->numeric()->disabled()->dehydrated(false), + Forms\Components\Toggle::make('approved')->label('Aprobat de client'), + Forms\Components\DatePicker::make('closed_at')->label('Închis'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(), + Tables\Columns\TextColumn::make('opened_at')->label('Deschis')->date('d.m.Y')->sortable(), + Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable(), + Tables\Columns\TextColumn::make('vehicle.plate')->label('Auto')->placeholder('—'), + Tables\Columns\TextColumn::make('master.name')->label('Maistru')->placeholder('—'), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($state) => WorkOrder::STATUSES[$state] ?? $state) + ->badge() + ->colors([ + 'gray' => ['new'], + 'info' => ['diagnosis', 'agreement', 'approved'], + 'warning' => ['in_work', 'awaiting_parts'], + 'success' => ['ready', 'done'], + 'danger' => ['cancelled'], + ]), + Tables\Columns\TextColumn::make('pay_status') + ->formatStateUsing(fn ($state) => WorkOrder::PAY_STATUSES[$state] ?? $state) + ->badge() + ->colors([ + 'danger' => ['unpaid'], + 'warning' => ['partial'], + 'success' => ['paid'], + ]), + Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight()->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status')->options(WorkOrder::STATUSES), + Tables\Filters\SelectFilter::make('pay_status')->options(WorkOrder::PAY_STATUSES), + Tables\Filters\SelectFilter::make('master_id') + ->label('Maistru') + ->options(fn () => User::pluck('name', 'id')), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('opened_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\WorksRelationManager::class, + RelationManagers\PartsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListWorkOrders::route('/'), + 'create' => Pages\CreateWorkOrder::route('/create'), + 'edit' => Pages\EditWorkOrder::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/Pages/CreateWorkOrder.php b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/CreateWorkOrder.php new file mode 100644 index 0000000..7074392 --- /dev/null +++ b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/CreateWorkOrder.php @@ -0,0 +1,20 @@ +currentId(); + $data['number'] = WorkOrder::generateNumber($companyId); + return $data; + } +} diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php new file mode 100644 index 0000000..6dabac0 --- /dev/null +++ b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php @@ -0,0 +1,17 @@ +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(), + ]); + } +} diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php new file mode 100644 index 0000000..dc7d099 --- /dev/null +++ b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php @@ -0,0 +1,79 @@ +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(), + ]); + } +} diff --git a/app/Models/Tenant/Labor.php b/app/Models/Tenant/Labor.php new file mode 100644 index 0000000..4c8ef88 --- /dev/null +++ b/app/Models/Tenant/Labor.php @@ -0,0 +1,28 @@ + 'decimal:2', + 'price' => 'decimal:2', + 'is_active' => 'boolean', + ]; +} diff --git a/app/Models/Tenant/User.php b/app/Models/Tenant/User.php index 8ef13f2..404e771 100644 --- a/app/Models/Tenant/User.php +++ b/app/Models/Tenant/User.php @@ -26,6 +26,7 @@ class User extends Authenticatable implements FilamentUser protected $fillable = [ 'company_id', 'name', 'email', 'phone', 'avatar_url', 'role', 'status', 'locale', + 'specialization', 'color', 'hourly_rate', 'email_verified_at', 'password', 'last_login_at', ]; diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php new file mode 100644 index 0000000..55bf594 --- /dev/null +++ b/app/Models/Tenant/WorkOrder.php @@ -0,0 +1,95 @@ + 'Nou', + 'diagnosis' => 'Diagnosticare', + 'agreement' => 'Aprobare client', + 'approved' => 'Aprobat', + 'in_work' => 'În lucru', + 'awaiting_parts' => 'Așteaptă piese', + 'ready' => 'Gata de ridicare', + 'done' => 'Predat', + 'cancelled' => 'Anulat', + ]; + + public const PAY_STATUSES = [ + 'unpaid' => 'Neplătit', + 'partial' => 'Parțial', + 'paid' => 'Plătit', + ]; + + protected $fillable = [ + 'company_id', 'number', + 'client_id', 'vehicle_id', 'master_id', 'deal_id', 'appointment_id', + 'opened_at', 'closed_at', 'mileage_in', 'mileage_out', + 'complaint', 'diagnosis', 'recommendations', + 'status', 'pay_status', 'approved', 'approved_at', + 'discount_pct', 'total', + ]; + + protected $casts = [ + 'opened_at' => 'date', + 'closed_at' => 'date', + 'approved_at' => 'datetime', + 'approved' => 'boolean', + 'discount_pct' => 'decimal:2', + 'total' => 'decimal:2', + ]; + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function master(): BelongsTo + { + return $this->belongsTo(User::class, 'master_id'); + } + + public function works(): HasMany + { + return $this->hasMany(WorkOrderWork::class); + } + + public function parts(): HasMany + { + return $this->hasMany(WorkOrderPart::class); + } + + public function recalcTotal(): void + { + $worksTotal = $this->works()->sum('total'); + $partsTotal = $this->parts()->sum('total'); + $sub = (float) $worksTotal + (float) $partsTotal; + $disc = (float) $this->discount_pct; + $this->total = round($sub * (1 - $disc / 100), 2); + $this->save(); + } + + 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('WO-%s-%04d', $year, $count + 1); + } +} diff --git a/app/Models/Tenant/WorkOrderPart.php b/app/Models/Tenant/WorkOrderPart.php new file mode 100644 index 0000000..ebd496c --- /dev/null +++ b/app/Models/Tenant/WorkOrderPart.php @@ -0,0 +1,52 @@ + 'Necesară', + 'ordered' => 'Comandată', + 'delivered' => 'Sosită', + 'installed' => 'Montată', + ]; + + protected $fillable = [ + 'company_id', 'work_order_id', + 'name', 'article', 'brand', + 'qty', 'unit', 'buy_price', 'sell_price', + 'discount_pct', 'total', 'status', 'notes', + ]; + + protected $casts = [ + 'qty' => 'decimal:2', + 'buy_price' => 'decimal:2', + 'sell_price' => 'decimal:2', + 'discount_pct' => 'decimal:2', + 'total' => 'decimal:2', + ]; + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + protected static function booted(): void + { + static::saving(function (self $row) { + $sub = (float) $row->qty * (float) $row->sell_price; + $disc = (float) $row->discount_pct; + $row->total = round($sub * (1 - $disc / 100), 2); + }); + static::saved(fn (self $row) => $row->workOrder?->recalcTotal()); + static::deleted(fn (self $row) => $row->workOrder?->recalcTotal()); + } +} diff --git a/app/Models/Tenant/WorkOrderWork.php b/app/Models/Tenant/WorkOrderWork.php new file mode 100644 index 0000000..2fc33e7 --- /dev/null +++ b/app/Models/Tenant/WorkOrderWork.php @@ -0,0 +1,55 @@ + 'De făcut', + 'in_progress' => 'În lucru', + 'done' => 'Finalizat', + ]; + + protected $fillable = [ + 'company_id', 'work_order_id', 'labor_id', 'master_id', + 'name', 'hours', 'price_per_hour', 'total', 'status', 'notes', + ]; + + protected $casts = [ + 'hours' => 'decimal:2', + 'price_per_hour' => 'decimal:2', + 'total' => 'decimal:2', + ]; + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function labor(): BelongsTo + { + return $this->belongsTo(Labor::class); + } + + public function master(): BelongsTo + { + return $this->belongsTo(User::class, 'master_id'); + } + + protected static function booted(): void + { + static::saving(function (self $row) { + $row->total = round((float) $row->hours * (float) $row->price_per_hour, 2); + }); + static::saved(fn (self $row) => $row->workOrder?->recalcTotal()); + static::deleted(fn (self $row) => $row->workOrder?->recalcTotal()); + } +} diff --git a/database/migrations/2026_05_06_180000_add_master_fields_to_users.php b/database/migrations/2026_05_06_180000_add_master_fields_to_users.php new file mode 100644 index 0000000..5d18c07 --- /dev/null +++ b/database/migrations/2026_05_06_180000_add_master_fields_to_users.php @@ -0,0 +1,24 @@ +string('specialization')->nullable()->after('locale'); + $t->string('color', 16)->nullable()->after('specialization'); + $t->decimal('hourly_rate', 8, 2)->nullable()->after('color'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $t) { + $t->dropColumn(['specialization', 'color', 'hourly_rate']); + }); + } +}; diff --git a/database/migrations/2026_05_06_180001_create_labors_table.php b/database/migrations/2026_05_06_180001_create_labors_table.php new file mode 100644 index 0000000..e3379e3 --- /dev/null +++ b/database/migrations/2026_05_06_180001_create_labors_table.php @@ -0,0 +1,37 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + + $t->string('category'); // Motor / Frâne / Suspensie / ... + $t->string('name_ro'); // numele manoperei (ro) + $t->string('name_ru')->nullable(); + $t->string('code', 32)->nullable(); // cod intern opțional + + $t->decimal('hours', 5, 2)->default(1); // norma-oră + $t->decimal('price', 10, 2)->default(0); // preț calculat (hours * tarif companie de obicei) + $t->boolean('is_active')->default(true); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->index(['company_id', 'category']); + $t->index(['company_id', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('labors'); + } +}; diff --git a/database/migrations/2026_05_06_180002_create_work_orders_tables.php b/database/migrations/2026_05_06_180002_create_work_orders_tables.php new file mode 100644 index 0000000..ebef82a --- /dev/null +++ b/database/migrations/2026_05_06_180002_create_work_orders_tables.php @@ -0,0 +1,96 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('number', 32); // WO-001 — generat per tenant + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('vehicle_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('master_id')->nullable()->constrained('users')->nullOnDelete(); + $t->foreignId('deal_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('appointment_id')->nullable()->constrained()->nullOnDelete(); + + $t->date('opened_at'); + $t->date('closed_at')->nullable(); + $t->unsignedInteger('mileage_in')->nullable(); + $t->unsignedInteger('mileage_out')->nullable(); + + $t->text('complaint')->nullable(); // jaluire client + $t->text('diagnosis')->nullable(); + $t->text('recommendations')->nullable(); + + $t->string('status')->default('new'); + // new / diagnosis / agreement / approved / in_work / + // awaiting_parts / ready / done / cancelled + $t->string('pay_status')->default('unpaid'); // unpaid / partial / paid + $t->boolean('approved')->default(false); + $t->timestamp('approved_at')->nullable(); + + $t->decimal('discount_pct', 5, 2)->default(0); + $t->decimal('total', 12, 2)->default(0); // calculat (works + parts - discount) + + $t->timestamps(); + $t->softDeletes(); + + $t->unique(['company_id', 'number']); + $t->index(['company_id', 'status']); + $t->index(['company_id', 'opened_at']); + }); + + Schema::create('wo_works', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $t->foreignId('labor_id')->nullable()->constrained()->nullOnDelete(); + $t->foreignId('master_id')->nullable()->constrained('users')->nullOnDelete(); + + $t->string('name'); // snapshot din labor.name_ro la momentul adăugării + $t->decimal('hours', 5, 2)->default(1); + $t->decimal('price_per_hour', 10, 2)->default(0); // tarif normo-oră + $t->decimal('total', 10, 2)->default(0); // hours * price_per_hour + $t->string('status')->default('todo'); // todo / in_progress / done + $t->text('notes')->nullable(); + + $t->timestamps(); + + $t->index(['company_id', 'work_order_id']); + }); + + Schema::create('wo_parts', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + + $t->string('name'); // ex: "Filtru ulei MANN W811/80" + $t->string('article', 64)->nullable(); + $t->string('brand', 64)->nullable(); + $t->decimal('qty', 8, 2)->default(1); + $t->string('unit', 16)->default('buc'); + $t->decimal('buy_price', 10, 2)->default(0); + $t->decimal('sell_price', 10, 2)->default(0); + $t->decimal('discount_pct', 5, 2)->default(0); + $t->decimal('total', 12, 2)->default(0); // qty * sell_price * (1-disc/100) + $t->string('status')->default('needed'); // needed / ordered / delivered / installed + $t->text('notes')->nullable(); + + $t->timestamps(); + + $t->index(['company_id', 'work_order_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('wo_parts'); + Schema::dropIfExists('wo_works'); + Schema::dropIfExists('work_orders'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 1323a77..bbc6258 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -8,10 +8,14 @@ use App\Models\Central\SuperAdmin; use App\Models\Tenant\Appointment; use App\Models\Tenant\Client; use App\Models\Tenant\Deal; +use App\Models\Tenant\Labor; use App\Models\Tenant\Lead; use App\Models\Tenant\Post; use App\Models\Tenant\User; use App\Models\Tenant\Vehicle; +use App\Models\Tenant\WorkOrder; +use App\Models\Tenant\WorkOrderPart; +use App\Models\Tenant\WorkOrderWork; use App\Tenancy\TenantManager; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; @@ -231,6 +235,87 @@ class DatabaseSeeder extends Seeder ] ); + // ─── Tehnicieni demo ────────────────────────────────────── + $masters = [ + ['Vasile Ivanov', 'Motor / Cutie viteze', '#3B82F6', '+373 69 111001'], + ['Andrei Popov', 'Suspensie / Frâne', '#E24B4A', '+373 69 222002'], + ['Nicolae Lupu', 'Electrică / Diagnosticare', '#10B981', '+373 69 333003'], + ]; + $masterUsers = []; + foreach ($masters as [$name, $spec, $color, $phone]) { + $email = strtolower(str_replace(' ', '.', \Illuminate\Support\Str::ascii($name))) . '@psauto.md'; + $u = User::firstOrCreate( + ['company_id' => $psauto->id, 'email' => $email], + [ + 'name' => $name, + 'phone' => $phone, + 'role' => 'mechanic', + 'status' => 'active', + 'specialization' => $spec, + 'color' => $color, + 'hourly_rate' => 400, + 'password' => Hash::make('mecanic123'), + 'email_verified_at' => now(), + ] + ); + $u->syncRoles(['mechanic']); + $masterUsers[$name] = $u; + } + + // ─── Catalog norme-ore ──────────────────────────────────── + $labors = [ + ['Motor', 'Schimb ulei și filtru', 'Замена масла и фильтра', 0.5, 200], + ['Motor', 'Schimb distribuție', 'Замена ГРМ', 4, 1600], + ['Motor', 'Diagnosticare motor', 'Диагностика двигателя', 1, 400], + ['Frâne', 'Schimb plăcuțe față', 'Замена колодок передних', 1, 400], + ['Frâne', 'Schimb plăcuțe spate', 'Замена колодок задних', 1.5, 600], + ['Frâne', 'Schimb discuri frână', 'Замена дисков', 1.5, 600], + ['Suspensie', 'Schimb amortizoare', 'Замена амортизаторов', 2, 800], + ['Suspensie', 'Geometrie roți', 'Развал-схождение', 1, 400], + ['Anvelope', 'Schimb anvelopă (1 buc)', 'Замена шины', 0.25, 100], + ['Electrică', 'Diagnosticare electrică', 'Диагностика электрики', 1, 400], + ]; + foreach ($labors as [$cat, $ro, $ru, $h, $p]) { + Labor::firstOrCreate( + ['company_id' => $psauto->id, 'name_ro' => $ro], + ['category' => $cat, 'name_ru' => $ru, 'hours' => $h, 'price' => $p, 'is_active' => true] + ); + } + + // ─── Fișă lucru demo ────────────────────────────────────── + $vasile = $masterUsers['Vasile Ivanov']; + $andrei = $masterUsers['Andrei Popov']; + + $wo = WorkOrder::firstOrCreate( + ['company_id' => $psauto->id, 'number' => 'WO-26-0001'], + [ + 'client_id' => $c1->id, + 'vehicle_id' => $v1->id, + 'master_id' => $andrei->id, + 'opened_at' => today()->subDays(2), + 'mileage_in' => 85000, + 'complaint' => 'Vibrație la frânare, scrâșnet roți față', + 'diagnosis' => 'Uzură plăcuțe + discuri față', + 'status' => 'in_work', + 'pay_status' => 'unpaid', + 'approved' => true, + 'approved_at' => today()->subDays(2), + ] + ); + WorkOrderWork::firstOrCreate( + ['company_id' => $psauto->id, 'work_order_id' => $wo->id, 'name' => 'Schimb plăcuțe față'], + ['hours' => 1, 'price_per_hour' => 400, 'status' => 'done', 'master_id' => $andrei->id] + ); + WorkOrderPart::firstOrCreate( + ['company_id' => $psauto->id, 'work_order_id' => $wo->id, 'name' => 'Plăcuțe Brembo P85020'], + [ + 'article' => 'P85020', 'brand' => 'Brembo', + 'qty' => 1, 'unit' => 'set', 'buy_price' => 280, 'sell_price' => 350, + 'status' => 'installed', + ] + ); + $wo->refresh()->recalcTotal(); + app(TenantManager::class)->clear(); } } diff --git a/routes/web.php b/routes/web.php index fbc403b..86a06c5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,179 +1,7 @@ getHost(); - $central = config('tenancy.central_domains', []); - $report = [ - 'host' => $host, - 'central_domains' => $central, - 'is_central' => in_array($host, $central, true), - ]; - - // Companies (always show) - $report['companies'] = Company::withoutGlobalScopes() - ->select('id', 'slug', 'name', 'status')->get()->toArray(); - - // Super admins - $report['super_admins'] = SuperAdmin::select('id', 'name', 'email', 'is_active')->get()->toArray(); - - // Try to resolve tenant from host - $centralPrimary = $central[0] ?? 'service.mir.md'; - $slug = str_ends_with($host, ".{$centralPrimary}") - ? substr($host, 0, -strlen(".{$centralPrimary}")) - : null; - - $report['detected_slug'] = $slug; - - if ($slug) { - $company = Company::where('slug', $slug)->first(); - $report['tenant_found'] = (bool) $company; - if ($company) { - $report['tenant'] = $company->only(['id', 'slug', 'name', 'status']); - - // Set tenant context to query users - app(TenantManager::class)->setCurrent($company); - - $users = User::select('id', 'company_id', 'email', 'name', 'role', 'status')->get()->toArray(); - $report['users_in_tenant'] = $users; - - // Test auth attempt - $admin = User::where('email', 'admin@psauto.md')->first(); - $report['admin_found'] = (bool) $admin; - if ($admin) { - $report['admin_check_password_admin123'] = Hash::check('admin123', $admin->password); - $report['admin_status'] = $admin->status; - $report['admin_can_access_panel'] = method_exists($admin, 'canAccessPanel') - ? 'method exists' : 'no method'; - } - } - } - - return response()->json($report, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); -}); - -Route::get('/__seed/{token}', function (string $token) { - if ($token !== 'kx9zMq7vR3aF2') { - abort(404); - } - try { - \Illuminate\Support\Facades\Artisan::call('db:seed', ['--force' => true]); - return response()->json([ - 'ok' => true, - 'output' => \Illuminate\Support\Facades\Artisan::output(), - ]); - } catch (\Throwable $e) { - return response()->json([ - 'ok' => false, - 'error' => $e->getMessage(), - 'file' => $e->getFile() . ':' . $e->getLine(), - 'trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 15), - ], 500); - } -}); - -Route::get('/__whoami/{token}', function (string $token, \Illuminate\Http\Request $request) { - if ($token !== 'kx9zMq7vR3aF2') abort(404); - $sess = $request->session(); - return response()->json([ - 'host' => $request->getHost(), - 'session_id' => $sess->getId(), - 'session_name' => $sess->getName(), - 'session_driver' => config('session.driver'), - 'session_keys' => array_keys($sess->all()), - 'auth_web_check' => auth('web')->check(), - 'auth_web_user' => auth('web')->user()?->only(['id', 'email', 'company_id']), - 'auth_default' => config('auth.defaults.guard'), - 'tenant_id' => app(\App\Tenancy\TenantManager::class)->currentId(), - ], 200, [], JSON_PRETTY_PRINT); -}); - -// Force-login endpoint to test session persistence (bypass Livewire/CSRF). -Route::get('/__force-login/{token}', function (string $token, \Illuminate\Http\Request $request) { - if ($token !== 'kx9zMq7vR3aF2') { - abort(404); - } - $email = $request->query('email', 'admin@psauto.md'); - $user = \App\Models\Tenant\User::where('email', $email)->first(); - if (! $user) { - return response('User not found', 404); - } - auth('web')->login($user, true); - $request->session()->regenerate(); - - $intended = url('/app'); - return response(' - -

✓ Force-login OK

-

User: '.e($user->email).' (id '.$user->id.')

-

Session ID: '.e($request->session()->getId()).'

-

Auth check: '.(auth('web')->check() ? 'YES' : 'NO').'

-

Cookie domain: '.e(config('session.domain') ?: '(null = host-only)').'

-

Now click → '.e($intended).'

- '); -}); - -// Test direct auth attempt + canAccessPanel -Route::get('/__try-login/{token}', function (string $token, \Illuminate\Http\Request $request) { - if ($token !== 'kx9zMq7vR3aF2') { - abort(404); - } - - $email = $request->query('email', 'admin@psauto.md'); - $pass = $request->query('pass', 'admin123'); - - $report = [ - 'host' => $request->getHost(), - 'tenant_resolved' => app(\App\Tenancy\TenantManager::class)->isResolved(), - 'tenant_id' => app(\App\Tenancy\TenantManager::class)->currentId(), - 'session_domain_config' => config('session.domain'), - 'session_secure_config' => config('session.secure'), - 'session_same_site' => config('session.same_site'), - 'app_url' => config('app.url'), - 'request_secure' => $request->isSecure(), - 'request_scheme' => $request->getScheme(), - ]; - - $user = \App\Models\Tenant\User::where('email', $email)->first(); - $report['user_lookup'] = (bool) $user; - - if ($user) { - $report['user_status'] = $user->status; - $report['password_check'] = \Illuminate\Support\Facades\Hash::check($pass, $user->password); - // Check canAccessPanel against tenant panel - try { - $panel = \Filament\Facades\Filament::getPanel('tenant'); - $report['panel_found'] = (bool) $panel; - $report['panel_id'] = $panel?->getId(); - $report['can_access_panel'] = $user->canAccessPanel($panel); - } catch (\Throwable $e) { - $report['panel_error'] = $e->getMessage(); - } - } - - // Try Auth::attempt - try { - $ok = auth('web')->attempt(['email' => $email, 'password' => $pass]); - $report['auth_attempt_result'] = $ok; - $report['authenticated_user_id'] = auth('web')->id(); - } catch (\Throwable $e) { - $report['auth_error'] = $e->getMessage(); - } - - return response()->json($report, 200, [], JSON_PRETTY_PRINT); -});