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:
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user