Files
autocrm/app/Filament/Tenant/Pages/Settings.php
T
Vasyka f4ccc306dc feat: marjă internă — Settings % + toggle per manoperă + visibility flag
Adds three usability improvements to the marja internă feature:

1. Settings UI section "Marjă internă (nu TVA)" — configurable at tenant level
2. Per-line toggle "Aplică marjă" on each manoperă
3. Global visibility flag to hide margin details in-session

== 1. Settings page ==

New section on /app/settings (gated by FINANCE_VIEW_INTERNAL_MARGIN):
- "% marjă implicit" numeric input with % suffix, 0–90 range
- "Afișează detalii marjă la procesele calculate" toggle (default on)
- Explicit label "Marjă internă (nu TVA)" plus helper text explaining
  it's not the Moldovan tax — feeds into MarginResolver as the tenant
  default, applied only when the mechanic has no per-user margin.

Persists as company.settings.default_internal_margin_pct and
company.settings.show_internal_margin_details.

== 2. Per-line "Aplică marjă" toggle ==

New wo_works.apply_margin boolean, default true. When false:
  applied_margin_pct = 0
  salary_base = total   (mechanic gets salaried on the full amount)

Use case: oil change, tire mount, and similar "at-cost" services where
the shop doesn't want to hold back part of the labor rate. The owner
can flag those specific lines while keeping margin on diagnostic and
premium labor.

WorksRelationManager form gains a Toggle field (gated by
FINANCE_VIEW_INTERNAL_MARGIN); table gains a ToggleColumn for quick
inline flipping without opening the row.

Booted hook now recomputes salary_base when apply_margin is dirtied,
so toggling live in the table takes effect immediately.

== 3. Show internal margin details flag ==

Global tenant flag (default on): when off, the gray subtitle line
"Bază salariu: 200 · marjă 20%" under the Total column disappears for
everyone, even users with FINANCE_VIEW_INTERNAL_MARGIN.

Practical use: when reviewing a Fișă face-to-face with the client on
the manager's screen, flip the flag off from Settings for the day →
no risk of the client accidentally seeing internal numbers. Flip back
when done.

The flag lives in company.settings.show_internal_margin_details.

== Description text on the Total column ==

Now shows either:
- "Bază salariu: 200.00 MDL · marjă 20%" when apply_margin=true
- "Fără marjă · bază salariu = Total" when apply_margin=false
- nothing when show_internal_margin_details=false or role lacks permission

== Tests ==

InternalMarginToggleTest (5):
- apply_margin=false → salary_base equals total, applied_margin_pct=0
- apply_margin=true (default) still applies 20% margin
- Toggling apply_margin recomputes salary_base bidirectionally
- Company default margin resolves when mechanic has no per-user setting
- show_internal_margin_details flag persists correctly in Company.settings

Suite: 303 passed (845 assertions). Was 298.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 19:39:51 +00:00

384 lines
21 KiB
PHP

<?php
namespace App\Filament\Tenant\Pages;
use App\Services\Notifications\TelegramService;
use App\Tenancy\TenantManager;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas;
use Filament\Schemas\Schema;
class Settings extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static ?string $navigationLabel = 'Setări';
protected static string|\UnitEnum|null $navigationGroup = 'Admin';
protected static ?int $navigationSort = 90;
protected static ?string $title = 'Setări companie';
protected string $view = 'filament.tenant.pages.settings';
public ?array $data = [];
public function mount(): void
{
$company = app(TenantManager::class)->current();
if (! $company) {
return;
}
$settings = (array) ($company->settings ?? []);
// Filament v5: fill via $this->form->fill() (initializes the schema state).
$notify = (array) ($settings['notify'] ?? []);
$this->form->fill([
'display_name' => $company->display_name ?? $company->name,
'city' => $company->city,
'phone' => $company->phone,
'email' => $company->email,
'currency' => $settings['currency'] ?? 'MDL',
'language' => $settings['language'] ?? 'ro',
'theme_color' => $settings['theme_color'] ?? '#3B82F6',
'labor_rate' => $settings['labor_rate'] ?? 400,
'services' => isset($settings['services']) ? implode(', ', (array) $settings['services']) : '',
'cars' => isset($settings['cars']) ? implode(', ', (array) $settings['cars']) : '',
'notify_wo_ready' => $notify['wo_ready'] ?? true,
'notify_payment' => $notify['payment'] ?? true,
'notify_appointment' => $notify['appointment'] ?? true,
'notify_reminder' => $notify['reminder'] ?? true,
'telegram_bot_token' => data_get($settings, 'telegram.bot_token'),
'reminder_after_days' => data_get($settings, 'reminder.after_days', 365),
'reminder_cooldown_days' => data_get($settings, 'reminder.cooldown_days', 30),
'default_internal_margin_pct' => $settings['default_internal_margin_pct'] ?? null,
'show_internal_margin_details' => data_get($settings, 'show_internal_margin_details', true),
'shop_enabled' => data_get($settings, 'shop.enabled', false),
'shop_delivery_methods' => data_get($settings, 'shop.delivery_methods', ['pickup']),
'shop_delivery_fee' => data_get($settings, 'shop.delivery_fee', 0),
'shop_free_delivery_over' => data_get($settings, 'shop.free_delivery_over', 0),
'ai_default_provider' => $settings['ai']['default_provider'] ?? 'claude',
'ai_claude_key' => $settings['ai']['claude_key'] ?? null,
'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null,
'ai_gemini_key' => $settings['ai']['gemini_key'] ?? null,
'ai_model_claude' => data_get($settings, 'ai.models.claude', \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['claude']),
'ai_model_gpt' => data_get($settings, 'ai.models.gpt', \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gpt']),
'ai_model_gemini' => data_get($settings, 'ai.models.gemini', \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini']),
]);
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Schemas\Components\Section::make('Brand & contact')
->columns(2)
->schema([
Forms\Components\TextInput::make('display_name')->label('Denumire afișată')->maxLength(120),
Forms\Components\TextInput::make('city')->label('Oraș')->maxLength(60),
Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->maxLength(40),
Forms\Components\TextInput::make('email')->email()->maxLength(120),
]),
Schemas\Components\Section::make('Localizare & monedă')
->columns(3)
->schema([
Forms\Components\Select::make('language')
->label('Limbă default')
->options(['ro' => 'Română', 'ru' => 'Русский', 'en' => 'English'])
->required(),
Forms\Components\Select::make('currency')
->label('Monedă')
->options([
'MDL' => 'MDL — Leu moldovenesc',
'EUR' => 'EUR — Euro',
'USD' => 'USD — US Dollar',
'RON' => 'RON — Leu românesc',
'UAH' => 'UAH — Hryvnia',
'RUB' => 'RUB — Rublă',
])
->required()
->searchable(),
Forms\Components\ColorPicker::make('theme_color')->label('Culoare brand'),
]),
Schemas\Components\Section::make('Servicii & tarif')
->columns(2)
->schema([
Forms\Components\TextInput::make('labor_rate')->label('Tarif normo-oră')->numeric()->required(),
]),
Schemas\Components\Section::make('Marjă internă (nu TVA)')
->description('Procent implicit aplicat la manopere. Se scade din prețul de manoperă pentru a determina baza salariului mecanicului. Vizibilă doar rolurilor cu drept „finance.view_internal_margin".')
->columns(2)
->visible(fn () => auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) ?? false)
->schema([
Forms\Components\TextInput::make('default_internal_margin_pct')
->label('% marjă implicit')
->numeric()
->step(0.01)
->minValue(0)
->maxValue(90)
->suffix('%')
->placeholder('Ex: 20')
->helperText('Fallback când mecanicul nu are marja proprie setată. Lasă gol = 0%.'),
Forms\Components\Toggle::make('show_internal_margin_details')
->label('Afișează detalii marjă la procesele calculate')
->helperText('On = arată „Bază salariu: X · marjă Y%" sub Total în tabelul Manopere. Off = doar Total (util când review-uiești o Fișă cu clientul de față).')
->default(true),
]),
Schemas\Components\Section::make('Liste configurabile')
->columns(1)
->schema([
Forms\Components\Textarea::make('services')
->label('Servicii oferite (separate prin virgulă)')
->rows(2),
Forms\Components\Textarea::make('cars')
->label('Mărci auto suportate (separate prin virgulă)')
->rows(2),
]),
Schemas\Components\Section::make('Logo & favicon')
->columns(2)
->schema([
Forms\Components\FileUpload::make('logo')
->label('Logo')
->image()
->imageEditor()
->disk('public')
->directory('tmp-uploads')
->visibility('public')
->maxSize(2048)
->helperText('PNG/SVG, max 2 MB. Apare în sidebar.'),
Forms\Components\FileUpload::make('favicon')
->label('Favicon')
->image()
->disk('public')
->directory('tmp-uploads')
->visibility('public')
->maxSize(512)
->helperText('PNG/ICO, max 512 KB.'),
]),
Schemas\Components\Section::make('Notificări')
->description('Activează / dezactivează notificările auto către clienți. Telegram are prioritate dacă clientul are cont legat.')
->columns(2)
->schema([
Forms\Components\Toggle::make('notify_wo_ready')->label('Mașina e gata de ridicat')->default(true),
Forms\Components\Toggle::make('notify_payment')->label('Confirmare plată primită')->default(true),
Forms\Components\Toggle::make('notify_appointment')->label('Programare confirmată')->default(true),
Forms\Components\Toggle::make('notify_reminder')->label('Reminder ITP / revizie')->default(true),
]),
Schemas\Components\Section::make('Telegram bot')
->description('Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook". Clienții îți scriu la bot, partajează telefonul, iar codul se leagă automat de fișa lor.')
->columns(1)
->schema([
Forms\Components\TextInput::make('telegram_bot_token')
->label('Bot token')
->password()
->revealable()
->placeholder('123456:ABC-XYZ...')
->helperText(fn () => 'Webhook URL: ' .
app(\App\Services\Notifications\TelegramService::class)
->webhookUrlFor(app(\App\Tenancy\TenantManager::class)->current())),
]),
Schemas\Components\Section::make('Reminder service auto')
->columns(2)
->schema([
Forms\Components\TextInput::make('reminder_after_days')
->label('Trimite reminder după X zile fără vizită')
->numeric()
->minValue(30)
->default(365),
Forms\Components\TextInput::make('reminder_cooldown_days')
->label('Nu re-trimite mai des de X zile')
->numeric()
->minValue(7)
->default(30),
]),
Schemas\Components\Section::make('Magazin online')
->description('Activează magazinul public la <slug>.service.mir.md/shop. Piesele apar doar dacă sunt marcate „Publicat".')
->columns(2)
->schema([
Forms\Components\Toggle::make('shop_enabled')->label('Magazin activ')->columnSpanFull(),
Forms\Components\CheckboxList::make('shop_delivery_methods')
->label('Metode de livrare')
->options(\App\Models\Tenant\OnlineOrder::DELIVERY)
->default(['pickup'])
->columnSpanFull(),
Forms\Components\TextInput::make('shop_delivery_fee')->label('Taxă livrare')->numeric()->default(0),
Forms\Components\TextInput::make('shop_free_delivery_over')->label('Livrare gratuită peste')->numeric()->default(0)->helperText('0 = dezactivat'),
]),
Schemas\Components\Section::make('Asistent AI')
->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.')
->columns(2)
->schema([
Forms\Components\Select::make('ai_default_provider')
->label('Provider implicit')
->options(['claude' => 'Claude (Anthropic)', 'gpt' => 'ChatGPT (OpenAI)', 'gemini' => 'Gemini (Google)'])
->default('claude'),
Forms\Components\TextInput::make('ai_claude_key')->label('Claude API Key')->password()->revealable()->placeholder('sk-ant-...'),
Forms\Components\Select::make('ai_model_claude')
->label('Model Claude')
->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['claude'])
->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['claude']),
Forms\Components\TextInput::make('ai_gpt_key')->label('OpenAI API Key')->password()->revealable()->placeholder('sk-proj-...'),
Forms\Components\Select::make('ai_model_gpt')
->label('Model OpenAI')
->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['gpt'])
->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gpt']),
Forms\Components\TextInput::make('ai_gemini_key')->label('Gemini API Key')->password()->revealable(),
Forms\Components\Select::make('ai_model_gemini')
->label('Model Gemini')
->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['gemini'])
->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini']),
]),
])
->statePath('data');
}
public function save(): void
{
$data = $this->form->getState();
$company = app(TenantManager::class)->current();
if (! $company) {
Notification::make()->title('Tenant not resolved')->danger()->send();
return;
}
$company->update([
'display_name' => $data['display_name'] ?? null,
'city' => $data['city'] ?? null,
'phone' => $data['phone'] ?? null,
'email' => $data['email'] ?? null,
'settings' => array_merge((array) $company->settings, [
'language' => $data['language'] ?? 'ro',
'currency' => $data['currency'] ?? 'MDL',
'theme_color' => $data['theme_color'] ?? '#3B82F6',
'labor_rate' => (float) ($data['labor_rate'] ?? 400),
'services' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['services'] ?? ''))))),
'cars' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['cars'] ?? ''))))),
'notify' => [
'wo_ready' => (bool) ($data['notify_wo_ready'] ?? true),
'payment' => (bool) ($data['notify_payment'] ?? true),
'appointment' => (bool) ($data['notify_appointment'] ?? true),
'reminder' => (bool) ($data['notify_reminder'] ?? true),
],
'telegram' => array_replace(
(array) data_get($company->settings, 'telegram', []),
['bot_token' => $data['telegram_bot_token'] ?? null]
),
'reminder' => [
'after_days' => (int) ($data['reminder_after_days'] ?? 365),
'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30),
],
'default_internal_margin_pct' => $data['default_internal_margin_pct'] !== '' && $data['default_internal_margin_pct'] !== null
? (float) $data['default_internal_margin_pct'] : null,
'show_internal_margin_details' => (bool) ($data['show_internal_margin_details'] ?? true),
'shop' => [
'enabled' => (bool) ($data['shop_enabled'] ?? false),
'delivery_methods' => array_values((array) ($data['shop_delivery_methods'] ?? ['pickup'])),
'delivery_fee' => (float) ($data['shop_delivery_fee'] ?? 0),
'free_delivery_over' => (float) ($data['shop_free_delivery_over'] ?? 0),
],
'ai' => [
'default_provider' => $data['ai_default_provider'] ?? 'claude',
'claude_key' => $data['ai_claude_key'] ?? null,
'gpt_key' => $data['ai_gpt_key'] ?? null,
'gemini_key' => $data['ai_gemini_key'] ?? null,
'models' => [
'claude' => $data['ai_model_claude'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['claude'],
'gpt' => $data['ai_model_gpt'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gpt'],
'gemini' => $data['ai_model_gemini'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini'],
],
],
]),
]);
// Logo + favicon → Spatie Media Library
foreach (['logo', 'favicon'] as $col) {
$path = $data[$col] ?? null;
if (! $path) continue;
$abs = \Illuminate\Support\Facades\Storage::disk('public')->path($path);
if (file_exists($abs)) {
$company->clearMediaCollection($col);
$company->addMedia($abs)->preservingOriginal()->toMediaCollection($col);
@unlink($abs);
}
}
Notification::make()->title('Setări salvate')->success()->send();
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('push_test')
->label('Test notificare push')
->icon('heroicon-m-bell-alert')
->color('gray')
->action(function () {
$svc = app(\App\Services\Notifications\WebPushService::class);
if (! $svc->configured()) {
Notification::make()
->title('Web Push neconfigurat')
->body('Rulează `php artisan push:vapid` și adaugă cheile în .env.')
->warning()->send();
return;
}
$r = $svc->sendToUser(
(int) auth()->id(),
'Test AutoCRM',
'Notificările push funcționează ✅',
'/app',
);
Notification::make()
->title($r['sent'] > 0 ? "Trimis pe {$r['sent']} dispozitiv(e)" : 'Niciun dispozitiv abonat')
->body($r['sent'] > 0 ? null : 'Deschide panoul pe telefon și acceptă notificările întâi.')
->{$r['sent'] > 0 ? 'success' : 'warning'}()
->send();
}),
Actions\Action::make('telegram_test')
->label('Testează bot Telegram')
->icon('heroicon-m-bolt')
->color('gray')
->action(function () {
$company = app(TenantManager::class)->current();
if (! $company) return;
$r = app(TelegramService::class)->getMe($company);
if (! ($r['ok'] ?? false)) {
Notification::make()
->title('Bot Telegram nu răspunde')
->body($r['error'] ?? 'Verifică token-ul.')
->danger()->send();
return;
}
$name = data_get($r, 'response.result.username', '?');
Notification::make()
->title("Bot OK: @{$name}")
->success()->send();
}),
Actions\Action::make('telegram_webhook')
->label('Setează webhook')
->icon('heroicon-m-link')
->color('primary')
->requiresConfirmation()
->modalDescription('Telegram va trimite mesajele primite la URL-ul webhook de mai jos.')
->action(function () {
$company = app(TenantManager::class)->current();
if (! $company) return;
$r = app(TelegramService::class)->setWebhook($company);
if (! ($r['ok'] ?? false)) {
Notification::make()
->title('Webhook eșuat')
->body($r['error'] ?? json_encode($r['response'] ?? []))
->danger()->send();
return;
}
Notification::make()
->title('Webhook setat — botul e gata')
->success()->send();
}),
];
}
}