Files
autocrm/app/Filament/Tenant/Pages/Settings.php
T
Vasyka 3911012c65 feat(i18n): mega-wrap 494 raw RO strings + 269 lang entries + 161 human RU/163 EN
Broad sweep across the whole codebase:
- Blade views (39 files, 315 wraps): tag-text and title/placeholder/alt
  attributes wrapped with {{ __() }}. Excludes scripts, styles, @php,
  @verbatim, {{ }}, {!! !!}, comments to avoid touching interpolations.
- PHP (54 files, 179 wraps): array 'key' => 'RO value' patterns and
  list items with diacritics wrapped with __(). Reverted __() inside
  const arrays (PHP disallows non-constant expressions).
- Added 269 new keys to lang/{ru,en}.json (identity fallback for
  unknowns → 161 human RU + 163 EN translations added for the most
  common enums, stages, roles, statuses, payment methods, vehicle
  categories, warehouse, portal, form actions.

Missing translations fall back to RO so the UI never breaks. All 306
tests pass; view cache compiles cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-15 05:53:22 +00:00

395 lines
22 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';
public static function getNavigationLabel(): string
{
return __('nav.label.Setări');
}
public static function getNavigationGroup(): ?string
{
return __('nav.group.Admin');
}
protected static ?int $navigationSort = 90;
protected static ?string $title = null;
public function getTitle(): string
{
return __('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();
}),
];
}
}