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>
This commit is contained in:
2026-07-26 11:27:53 +00:00
parent 0e85035eff
commit 47eb4f62c1
20 changed files with 1218 additions and 8 deletions
@@ -0,0 +1,288 @@
<?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'),
];
}
}