Files
autocrm/app/Filament/Tenant/Resources/InjectorProtocolResource.php
T
Vasyka 47eb4f62c1 feat(injector-protocols): full module — resource + PDF export via Browsershot
Implements the injector diagnostic protocol module per spec:
- Migration: injector_protocols + injector_protocol_rows with company_id
  (multi-tenant), vehicle_id (not car_id — this CRM uses Vehicle),
  master_id FK to users, snapshot fields (car_model/plate/vin/mileage/year/
  injector_brand) captured at protocol time.
- Models with BelongsToTenant; auto-generate protocol_number as
  PS-{tenantId}-{year}-{seq6}; auto-seed 8 empty rows on create.
- Permissions: injector_protocols.view + injector_protocols.conclude.
  Default roles: owner/admin (both), manager (both), receptionist (view
  only), mechanic (both). Test permission count updated 52→54.
- InjectorProtocolResource under Service nav group with 4 sections:
  data auto+client (with client_id/vehicle_id lookups auto-filling
  snapshot fields), reason checkboxes, 8-row repeater for measurements
  (Repeater with position hidden; fixed 8, non-addable/deletable),
  conclusion + comment + master signature.
- Table with columns, badge filters, PDF action.
- Blade PDF template: pixel-close to reference (graphite/gold/blue
  brand tokens, striped rows, rotated 'ФОРСУНКИ' header).
- Route /app/injector-protocols/{id}/pdf with permission gate,
  streams PDF via new InjectorProtocolPdfService (Browsershot).
- Dockerfile: install nodejs 22 + chromium + noto fonts + puppeteer-core
  in /opt/browsershot; env vars for Browsershot binary paths.
- +71 RU/EN translations covering resource + PDF template.

All 306 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-26 11:27:53 +00:00

289 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Filament\Tenant\Resources;
use App\Auth\Permissions;
use App\Filament\Tenant\Resources\InjectorProtocolResource\Pages;
use App\Models\Tenant\Client;
use App\Models\Tenant\InjectorProtocol;
use App\Models\Tenant\User;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class InjectorProtocolResource extends Resource
{
protected static ?string $model = InjectorProtocol::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-beaker';
public static function getNavigationLabel(): string
{
return __('nav.label.Protocoale forsunki');
}
public static function getNavigationGroup(): ?string
{
return __('nav.group.Service');
}
protected static ?int $navigationSort = 27;
public static function getModelLabel(): string
{
return __('protocol forsunki');
}
public static function getPluralModelLabel(): string
{
return __('protocoale forsunki');
}
public static function canViewAny(): bool
{
return auth()->user()?->canDo(Permissions::INJECTOR_PROTOCOLS_VIEW) ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->canDo(Permissions::INJECTOR_PROTOCOLS_CONCLUDE) ?? false;
}
public static function canEdit($record): bool
{
return auth()->user()?->canDo(Permissions::INJECTOR_PROTOCOLS_CONCLUDE) ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make(__('Date auto & client'))
->columns(3)
->schema([
Forms\Components\TextInput::make('protocol_number')
->label(__('Nr. protocol'))
->disabled()
->dehydrated(false)
->placeholder(__('Generat automat')),
Forms\Components\Select::make('source_type')
->label(__('Sursă'))
->options(\App\Support\I18n::opts(InjectorProtocol::SOURCE_TYPES))
->default('client')
->required(),
Forms\Components\TextInput::make('source_name')
->label(__('Autoservis / Client'))
->required()
->maxLength(160),
Forms\Components\Select::make('client_id')
->label(__('Client CRM'))
->options(fn () => Client::pluck('name', 'id'))
->searchable()
->live()
->afterStateUpdated(function ($state, Set $set) {
if ($state && $c = Client::find($state)) {
$set('source_name', $c->name);
$set('phone', $c->phone);
}
}),
Forms\Components\Select::make('vehicle_id')
->label(__('Auto CRM'))
->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()
: Vehicle::get()->mapWithKeys(fn ($v) => [$v->id => "{$v->make} {$v->model} {$v->plate}"])->toArray())
->searchable()
->live()
->afterStateUpdated(function ($state, Set $set) {
if ($state && $v = Vehicle::find($state)) {
$set('car_model', trim(($v->make ?? '') . ' ' . ($v->model ?? '')));
$set('plate_number', $v->plate);
$set('vin', $v->vin);
$set('year', $v->year);
$set('mileage', $v->mileage);
}
}),
Forms\Components\Select::make('work_order_id')
->label(__('Fișă lucru (opțional)'))
->options(fn () => WorkOrder::orderBy('id', 'desc')->limit(50)
->get()->mapWithKeys(fn ($w) => [$w->id => "{$w->number}" . ($w->client?->name ?? '?')])->toArray())
->searchable(),
Forms\Components\TextInput::make('phone')->label(__('Telefon'))->tel()->maxLength(40),
Forms\Components\TextInput::make('plate_number')->label(__('Nr. înmatriculare'))->maxLength(32),
Forms\Components\TextInput::make('vin')->label(__('VIN'))->maxLength(17),
Forms\Components\TextInput::make('car_model')->label(__('Model auto'))->maxLength(120),
Forms\Components\TextInput::make('year')->label(__('An'))->numeric()->minValue(1950)->maxValue(2100),
Forms\Components\TextInput::make('mileage')->label(__('Kilometraj'))->numeric()->suffix(__('km')),
Forms\Components\TextInput::make('injector_brand')->label(__('Marca injectoarelor'))->maxLength(60),
Forms\Components\TextInput::make('injector_count')->label(__('Nr. injectoare'))
->numeric()->minValue(1)->maxValue(8)->default(8)->required(),
]),
Schemas\Components\Section::make(__('Cauza adresării'))
->columns(3)
->schema([
Forms\Components\Checkbox::make('reason_new_injectors')->label(__('Injectoare noi')),
Forms\Components\Checkbox::make('reason_engine_overhaul')->label(__('Reparație capitală motor')),
Forms\Components\Checkbox::make('reason_check')->label(__('Verificare / diagnostic')),
]),
Schemas\Components\Section::make(__('Măsurători mecanica injectoarelor'))
->description(__('8 forsunki × 6 măsurători (Înainte / După). Introducerea se face pe rând.'))
->schema([
Forms\Components\Repeater::make('rows')
->label('')
->relationship('rows')
->schema([
Forms\Components\TextInput::make('position')
->label(__('№'))
->numeric()->minValue(1)->maxValue(8)->required()
->disabled()->dehydrated(true),
Forms\Components\TextInput::make('resistance_before')->label(__('Rezistență ÎNAINTE'))->numeric()->step(0.01)->suffix('Ω'),
Forms\Components\TextInput::make('resistance_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix('Ω'),
Forms\Components\TextInput::make('open_time_before')->label(__('Deschidere ÎNAINTE'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('open_time_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('close_time_before')->label(__('Închidere ÎNAINTE'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('close_time_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('close_time_kz_before')->label(__('Închidere KZ ÎNAINTE'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('close_time_kz_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('flow_before')->label(__('Debit ÎNAINTE'))->numeric()->step(0.01)->suffix(__('ml')),
Forms\Components\TextInput::make('flow_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix(__('ml')),
Forms\Components\TextInput::make('tightness_before')->label(__('Etanșeitate ÎNAINTE'))->maxLength(20),
Forms\Components\TextInput::make('tightness_after')->label(__('DUPĂ'))->maxLength(20),
])
->columns(2)
->itemLabel(fn (array $state) => __('Forsunka') . ' #' . ($state['position'] ?? '?'))
->addable(false)
->deletable(false)
->reorderable(false)
->collapsed(fn ($record) => (bool) $record),
]),
Schemas\Components\Section::make(__('Заключение'))
->columns(3)
->schema([
Forms\Components\Checkbox::make('conclusion_ok')
->label(__('Injectoarele în normă, apte pentru exploatare')),
Forms\Components\Checkbox::make('conclusion_needs_cleaning')
->label(__('Necesită curățare repetată')),
Forms\Components\Checkbox::make('conclusion_needs_replacement')
->label(__('Recomandată înlocuire')),
Forms\Components\Textarea::make('master_comment')
->label(__('Comentariu / recomandări maistru'))
->rows(3)
->columnSpanFull(),
Forms\Components\Select::make('master_id')
->label(__('Maistru'))
->options(fn () => User::whereIn('role', ['mechanic', 'admin', 'owner'])
->pluck('name', 'id'))
->searchable(),
Forms\Components\TextInput::make('master_name_signed')
->label(__('Semnat de (manual)'))
->maxLength(120)
->helperText(__('Doar dacă maistrul nu e în listă.')),
Forms\Components\DatePicker::make('issue_date')
->label(__('Data emiterii'))
->default(today()),
Forms\Components\Checkbox::make('client_acknowledged')
->label(__('Client informat / a luat cunoștință'))
->columnSpanFull(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('protocol_number')
->label(__('Nr. protocol'))
->searchable()
->sortable()
->copyable()
->weight('bold'),
Tables\Columns\TextColumn::make('issue_date')
->label(__('Data'))
->date('d.m.Y')
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('source_name')
->label(__('Autoservis / Client'))
->searchable()
->wrap(),
Tables\Columns\TextColumn::make('plate_number')
->label(__('Nr. auto'))
->searchable()
->placeholder('—'),
Tables\Columns\TextColumn::make('injector_brand')
->label(__('Marca'))
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('injector_count')
->label(__('Injectoare'))
->alignCenter(),
Tables\Columns\IconColumn::make('conclusion_ok')
->label(__('OK'))
->boolean()
->toggleable(),
Tables\Columns\IconColumn::make('conclusion_needs_cleaning')
->label(__('Curățare'))
->boolean()
->toggleable(),
Tables\Columns\IconColumn::make('conclusion_needs_replacement')
->label(__('Înlocuire'))
->boolean()
->toggleable(),
Tables\Columns\TextColumn::make('master.name')
->label(__('Maistru'))
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label(__('Creat'))
->dateTime('d.m.Y H:i')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('source_type')
->label(__('Sursă'))
->options(\App\Support\I18n::opts(InjectorProtocol::SOURCE_TYPES)),
Tables\Filters\Filter::make('needs_action')
->label(__('Necesită acțiune'))
->query(fn ($q) => $q->where(fn ($q) => $q
->where('conclusion_needs_cleaning', true)
->orWhere('conclusion_needs_replacement', true))),
])
->actions([
Actions\Action::make('pdf')
->label(__('PDF'))
->icon('heroicon-m-document-arrow-down')
->color('gray')
->url(fn (InjectorProtocol $r) => route('tenant.injector-protocols.pdf', ['record' => $r->id]))
->openUrlInNewTab(),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading(__('Niciun protocol'))
->emptyStateDescription(__('Creează primul protocol de diagnostic forsunki. Numărul se generează automat (PS-{tenant}-{an}-{seq}).'))
->emptyStateIcon('heroicon-o-beaker')
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListInjectorProtocols::route('/'),
'create' => Pages\CreateInjectorProtocol::route('/create'),
'edit' => Pages\EditInjectorProtocol::route('/{record}/edit'),
];
}
}