Files
autocrm/app/Filament/Tenant/Resources/WorkOrderResource.php
T
Vasyka 5a5f43f2de feat(pdf): inline preview by default with ?download=1 override + vehicle photo fallback
- WorkOrder and InjectorProtocol PDF routes now respond with
  Content-Disposition: inline (opens in browser tab so the user can
  read/print/save from the built-in PDF viewer). ?download=1 forces
  the classic attachment download.
- CalendarBoard exportPdf() switched to the same inline default.
- WO edit action + WO table row action + injector protocol actions
  now use ->url(...)->openUrlInNewTab() instead of streaming the PDF
  inline into the current tab.
- Dashboard Docs tab has both a preview link and a "Descarcă" link.
- Vehicle card photo falls back to WO's first uploaded photo when
  Vehicle model has no MediaLibrary integration of its own.

Test CalendarEnhancementsTest updated to assert the new inline
response headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-05 20:56:24 +00:00

221 lines
11 KiB
PHP

<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\WorkOrderResource\Pages;
use App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
use App\Models\Tenant\Client;
use App\Models\Tenant\User;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class WorkOrderResource extends Resource
{
protected static ?string $model = WorkOrder::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
public static function getNavigationLabel(): string
{
return __('nav.label.Fișe lucru');
}
public static function getNavigationGroup(): ?string
{
return __('nav.group.Service');
}
protected static ?string $modelLabel = null;
public static function getModelLabel(): string
{
return __('fișă');
}
protected static ?string $pluralModelLabel = null;
public static function getPluralModelLabel(): string
{
return __('fișe lucru');
}
protected static ?int $navigationSort = 30;
public static function getGloballySearchableAttributes(): array
{
return ['number', 'complaint', 'diagnosis', 'vehicle.plate', 'vehicle.vin', 'client.name', 'client.phone'];
}
public static function getGlobalSearchResultTitle(\Illuminate\Database\Eloquent\Model $record): string
{
return '#' . ($record->number ?? $record->id) . ' · ' . ($record->vehicle?->plate ?? '?');
}
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
{
return [
'Client' => $record->client?->name ?? '—',
'Status' => $record->status,
'Total' => number_format((float) $record->total, 2),
];
}
public static function form(Schema $schema): Schema
{
return $schema->components([
// Antet compact — o singură secțiune densă cu doar esențialul, mereu deschisă.
Schemas\Components\Section::make(__('Antet fișă'))
->description(__('Informația de bază — click pe titlul secțiunilor de mai jos pentru detalii.'))
->compact()
->columns(4)
->schema([
Forms\Components\TextInput::make('number')->label(__('Nr.'))->disabled()->dehydrated(false)->placeholder('auto'),
Forms\Components\DatePicker::make('opened_at')->label(__('Deschis'))->default(today())->required(),
Forms\Components\Select::make('status')->options(\App\Support\I18n::opts(WorkOrder::STATUSES))->default('new')->required(),
Forms\Components\Select::make('urgency')->label(__('Urgență'))
->options(\App\Support\I18n::opts(\App\Models\Tenant\PricingCoefficient::URGENCY))->default('normal')->required(),
Forms\Components\Select::make('client_id')->label(__('Client'))
->options(fn () => Client::pluck('name', 'id'))
->searchable()->live()->required()->columnSpan(2),
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()->columnSpan(2),
Forms\Components\Select::make('master_id')->label(__('Maistru'))
->options(fn () => User::where('status', 'active')->pluck('name', 'id'))
->searchable()->columnSpan(2),
Forms\Components\TextInput::make('mileage_in')->label(__('Km intrare'))->numeric(),
Forms\Components\TextInput::make('mileage_out')->label(__('Km ieșire'))->numeric(),
]),
// Toate secțiunile de detaliu — collapsible și collapsed by default
Schemas\Components\Section::make(__('Diagnostic'))
->collapsible()->collapsed()->compact()
->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(__('Foto'))
->collapsible()->collapsed()->compact()
->schema([
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos')
->label(__('Fotografii'))->collection('photos')
->multiple()->reorderable()->image()->imageEditor()->maxFiles(20)
->columnSpanFull(),
]),
Schemas\Components\Section::make(__('Tracking & ETA'))
->columns(3)->collapsible()->collapsed()->compact()
->schema([
Forms\Components\DateTimePicker::make('eta_at')->label(__('Gata estimat (ETA)'))->seconds(false),
Forms\Components\TextInput::make('tracking_token')->label(__('Token public'))
->disabled()->dehydrated(false)->columnSpan(2)
->helperText(fn (?WorkOrder $record) => $record?->tracking_token
? 'Link client: ' . $record->trackingUrl()
: 'Se generează la salvare'),
]),
Schemas\Components\Section::make(__('Plată & total'))
->columns(3)->collapsible()->collapsed()->compact()
->schema([
Forms\Components\Select::make('pay_status')->options(\App\Support\I18n::opts(WorkOrder::PAY_STATUSES))->default('unpaid')->required(),
Forms\Components\TextInput::make('discount_pct')->label(__('Discount %'))->numeric()->default(0),
Forms\Components\Toggle::make('apply_margin')
->label(__('Aplică marjă internă'))
->default(true)
->helperText(__('On = din prețul manoperelor se scade marja pentru salariu. Off = manopere la cost (salariu pe Total integral).'))
->visible(fn () => auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) ?? false),
Forms\Components\TextInput::make('override_margin_pct')->label(__('Marjă internă (%) — override'))
->numeric()->step(0.01)->minValue(0)->maxValue(90)
->placeholder(__('Ex: 25'))
->helperText(__('Doar pentru cazuri speciale. Lasă gol → folosește marja mecanicului.'))
->visible(fn () => auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) ?? false),
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(\App\Support\I18n::opts(WorkOrder::STATUSES)),
Tables\Filters\SelectFilter::make('pay_status')->options(\App\Support\I18n::opts(WorkOrder::PAY_STATUSES)),
Tables\Filters\SelectFilter::make('master_id')
->label(__('Maistru'))
->options(fn () => User::pluck('name', 'id')),
])
->actions([
Actions\Action::make('pdf')
->label(__('PDF'))
->icon('heroicon-m-document-magnifying-glass')
->color('gray')
->url(fn (WorkOrder $r) => url('/app/work-orders/' . $r->id . '/pdf'))
->openUrlInNewTab(),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading(__('Nicio fișă de lucru'))
->emptyStateDescription(__('Crează prima fișă pentru o mașină existentă. Adaugă manopere, piese, plăți — totalul se calculează automat.'))
->emptyStateIcon('heroicon-o-wrench-screwdriver')
->defaultSort('opened_at', 'desc');
}
public static function getRelations(): array
{
return [
RelationManagers\WorksRelationManager::class,
RelationManagers\PartsRelationManager::class,
RelationManagers\SubcontractJobsRelationManager::class,
RelationManagers\PaymentsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListWorkOrders::route('/'),
'create' => Pages\CreateWorkOrder::route('/create'),
'edit' => Pages\EditWorkOrder::route('/{record}/edit'),
];
}
}