current(); return (float) data_get($company?->settings, 'min_discount_margin_pct', 0); } public function form(Schema $schema): Schema { return $schema->components([ Forms\Components\Select::make('labor_id') ->label(__('Catalog manoperă') . ' (' . __('opțional') . ')') ->options(fn () => Labor::where('is_active', true) ->get() ->mapWithKeys(fn ($l) => [$l->id => '[' . __($l->category) . '] ' . $l->label() . ' (' . $l->hours . __('h') . ')']) ->toArray()) ->searchable() ->live() ->afterStateUpdated(function ($state, Set $set) { if ($state && $labor = Labor::find($state)) { $set('name', $labor->label()); $set('hours', $labor->hours); $set('price_per_hour', $labor->hours > 0 ? round($labor->price / max((float) $labor->hours, 1), 2) : 0); $set('cost_price', $labor->cost_price); $set('discount_pct', 0); } }) ->createOptionForm([ Forms\Components\Select::make('category')->label(__('Categorie')) ->options(\App\Support\I18n::opts(array_combine(Labor::CATEGORIES, Labor::CATEGORIES))) ->required(), Forms\Components\TextInput::make('name_ro')->label(__('Denumire'))->required(), Forms\Components\TextInput::make('hours')->label(__('Ore normate'))->numeric()->default(1)->required(), Forms\Components\TextInput::make('price')->label(__('Preț total (MDL)'))->numeric()->default(0)->required(), Forms\Components\TextInput::make('cost_price')->label(__('Preț cost (MDL)'))->numeric()->default(0), ]) ->createOptionUsing(function (array $data) { $data['is_active'] = true; return Labor::create($data)->id; }) ->columnSpanFull(), Forms\Components\TextInput::make('name')->label(__('Nume'))->required()->columnSpanFull(), Forms\Components\TextInput::make('hours')->label(__('Ore'))->numeric()->default(1)->required()->live(onBlur: true), Forms\Components\TextInput::make('price_per_hour')->label(__('Preț/h'))->numeric()->required()->live(onBlur: true), Forms\Components\TextInput::make('cost_price') ->label(__('Preț cost (MDL)')) ->numeric() ->default(0) ->helperText(__('Costul intern al manoperei — limita minimă de vânzare')), Forms\Components\TextInput::make('discount_pct') ->label(__('Reducere %')) ->numeric() ->default(0) ->minValue(0) ->maxValue(100) ->step(0.01) ->live(onBlur: true) ->rules([ fn (Get $get): \Closure => function (string $attribute, $value, \Closure $fail) use ($get) { $hours = max(0, (float) $get('hours')); $rate = max(0, (float) $get('price_per_hour')); $costPrice = max(0, (float) $get('cost_price')); $disc = min(100, max(0, (float) $value)); $gross = $hours * $rate; $effective = round($gross * (1 - $disc / 100), 2); if ($costPrice > 0 && $effective < $costPrice) { $fail(__('Prețul efectiv (:eff MDL) este sub prețul de cost (:cost MDL).', [ 'eff' => number_format($effective, 2), 'cost' => number_format($costPrice, 2), ])); return; } $minMargin = self::minMargin(); if ($minMargin > 0 && $gross > 0 && $costPrice > 0) { $margin = ($effective - $costPrice) / $effective * 100; if ($margin < $minMargin) { $fail(__('Marja (:m%) este sub limita minimă (:min%). Necesită permisiune specială.', [ 'm' => number_format($margin, 1), 'min' => number_format($minMargin, 1), ])); } } }, ]) ->helperText(function (Get $get, $state) { $hours = max(0, (float) $get('hours')); $rate = max(0, (float) $get('price_per_hour')); $cost = max(0, (float) $get('cost_price')); $disc = min(100, max(0, (float) $state)); $gross = $hours * $rate; $effective = round($gross * (1 - $disc / 100), 2); if ($gross <= 0) return null; $minMargin = self::minMargin(); if ($cost > 0 && $gross > 0) { $margin = $effective > 0 ? ($effective - $cost) / $effective * 100 : 0; $color = ($margin < $minMargin && $minMargin > 0) ? '🔴' : '🟢'; return "{$color} " . __('Efectiv:') . ' ' . number_format($effective, 2) . ' MDL · ' . __('Marjă:') . ' ' . number_format($margin, 1) . '%'; } return __('Efectiv:') . ' ' . number_format($effective, 2) . ' MDL'; }), Forms\Components\Select::make('master_id') ->label(__('Maistru')) ->options(fn () => User::pluck('name', 'id')) ->searchable(), Forms\Components\Select::make('status') ->options(\App\Support\I18n::opts(WorkOrderWork::STATUSES)) ->default('todo') ->required(), Forms\Components\Textarea::make('notes')->label(__('Notițe'))->columnSpanFull()->rows(2), ]); } private static function marginDetailsVisible(): bool { if (! auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN)) { return false; } $tenant = app(TenantManager::class)->current(); return (bool) data_get($tenant?->settings, 'show_internal_margin_details', true); } public function table(Table $table): Table { return $table ->recordTitleAttribute('name') ->columns([ Tables\Columns\TextColumn::make('name')->label(__('Manoperă'))->wrap() ->getStateUsing(fn ($record) => $record->labor?->label() ?: $record->name), 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('discount_pct')->label(__('Discount'))->alignRight() ->formatStateUsing(fn ($state) => $state > 0 ? number_format((float)$state, 1) . '%' : '—') ->color(fn ($state) => $state > 0 ? 'warning' : null), Tables\Columns\TextColumn::make('total') ->label(__('Total')) ->money('MDL') ->alignRight() ->description(function ($record) { if (! self::marginDetailsVisible() || $record->salary_base === null) return null; if ($record->workOrder && $record->workOrder->apply_margin === false) return null; if ((float) $record->applied_margin_pct === 0.0) return null; return 'Bază salariu: ' . number_format((float) $record->salary_base, 2) . ' MDL · marjă ' . rtrim(rtrim(number_format((float) $record->applied_margin_pct, 2), '0'), '.') . '%'; }), 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() ->label(__('Adaugă manoperă')) ->modalHeading(__('Adaugă manoperă în fișă')) ->after(function (WorkOrderWork $record) { if (! $record->labor_id) return; $labor = Labor::with('laborParts.part')->find($record->labor_id); $wo = $record->workOrder; if (! $labor || ! $wo || $labor->laborParts->isEmpty()) return; $composer = app(\App\Services\ServiceComposer::class); foreach ($labor->laborParts as $lp) { if ($lp->part) { $composer->addPart($wo, $lp->part, (float) $lp->qty, $lp->unit); } } \Filament\Notifications\Notification::make() ->title(__('Piese implicite adăugate (:n)', ['n' => $labor->laborParts->count()])) ->success()->send(); }), Actions\Action::make('bulk_discount_works') ->label(__('Reducere globală')) ->icon('heroicon-m-tag') ->color('warning') ->schema([ Forms\Components\TextInput::make('discount_pct') ->label(__('Reducere % (toate manoperele)')) ->numeric() ->minValue(0) ->maxValue(100) ->step(0.01) ->required() ->helperText(__('Aplică același % de reducere la toate liniile de manoperă din această fișă.')), ]) ->action(function (array $data) { $disc = min(100, max(0, (float) $data['discount_pct'])); $minMargin = self::minMargin(); $blocked = 0; foreach ($this->getOwnerRecord()->works as $work) { $gross = (float) $work->hours * (float) $work->price_per_hour; $effective = round($gross * (1 - $disc / 100), 2); $cost = (float) $work->cost_price; if ($cost > 0 && $effective < $cost) { $blocked++; continue; } if ($minMargin > 0 && $effective > 0 && $cost > 0) { $margin = ($effective - $cost) / $effective * 100; if ($margin < $minMargin) { $blocked++; continue; } } $work->update(['discount_pct' => $disc]); } $msg = $blocked > 0 ? __(':n linii blocate (sub limita de marjă sau preț cost).', ['n' => $blocked]) : null; \Filament\Notifications\Notification::make() ->title(__('Reducere aplicată')) ->body($msg) ->{$blocked > 0 ? 'warning' : 'success'}() ->send(); }), ]) ->actions([ Actions\EditAction::make(), Actions\DeleteAction::make(), ]) ->emptyStateHeading(__('Nicio manoperă efectuată.')) ->emptyStateDescription(__('Adaugă manopere din catalog — piesele implicite se adaugă automat în tab-ul Piese.')) ->emptyStateIcon('heroicon-o-wrench'); } }