Compare commits

...

12 Commits

Author SHA1 Message Date
Vasyka 9b97e101ff security: patch PHP2Shell RCE CVE-2026-109618 + CVE-2026-109619
Pin base image from floating dunglas/frankenphp:1-php8.4 to
dunglas/frankenphp:1.13.4-php8.4.27 which ships PHP 8.4.27.

CVE-2026-109618: OOM memory corruption → unauthenticated RCE (CVSS 9.2)
CVE-2026-109619: use-after-free between requests → unauthenticated RCE (CVSS 9.2)
Both affect PHP-FPM, mod_php, FrankenPHP, php-cgi. Exploited in the wild.
Fixed upstream in PHP 8.2.35, 8.3.36, 8.4.27, 8.5.12.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-27 09:17:22 +00:00
Vasyka 16fcdb41ed fix: phone uniqueness validation and DB constraint for users
- Replaced broken ->rules(fn (\$record)) with proper Filament ->unique()
  using ignoreRecord:true + modifyRuleUsing scoped to company_id,
  applied to both phone and email in MasterResource and UserResource
- Added migration 2026_09_26_000001 to add unique(company_id, phone)
  constraint on users table (nulls out duplicate phones before adding,
  MySQL allows multiple NULLs in unique index so blank phones are fine)
- Exception catch in Create/EditMaster and Create/EditUser now also
  handles UniqueConstraintViolationException on phone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-26 20:18:55 +00:00
Vasyka f1afcafa5d fix: duplicate email/phone shows notification instead of crashing
Two-layer protection on MasterResource and UserResource:
1. Form validation via ->rules() with Rule::unique() scoped to company_id
   (prevents DB hit in normal flow, shows inline field error)
2. handleRecordCreation/handleRecordUpdate catches
   UniqueConstraintViolationException as fallback — shows a Filament
   danger notification and halts without showing an error page

Also adds phone uniqueness validation (scoped per tenant).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-26 20:16:13 +00:00
Vasyka fcbb807462 fix: show friendly validation error for duplicate email on user/master create
Added ->unique(ignoreRecord: true) scoped to company_id on the email
field in both MasterResource and UserResource. Previously a DB-level
UniqueConstraintViolationException crashed the page; now Filament shows
"Acest email este deja folosit de un alt utilizator." inline on the field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-26 20:11:56 +00:00
Vasyka ab0b30af11 feat: inject cookie banner into both Filament panels via renderHook
Adds BODY_END renderHook in TenantPanelProvider and CentralPanelProvider
so the cookie consent banner appears on /app/* and /admin/* pages.
Alpine.js is already bundled by Livewire/Filament — no CDN needed.
Tenant panel uses tenant theme color; central panel uses indigo default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 20:46:45 +00:00
Vasyka c3e4b99d4b feat: complete cookie consent with categories & technical processing
- Full 4-category consent modal: Necesare (locked) / Funcționale /
  Analitice / Marketing with toggle switches per category
- Banner shows simple view first; "Personalizează" opens detailed panel
- Consent stored as JSON in localStorage (cookie_consent_v2)
- window.cookieConsent object set on load; dispatches
  cookie-consent-updated / cookie-analytics-granted / cookie-marketing-granted
  CustomEvents for deferred script loading
- Floating 🍪 FAB button lets users re-open settings at any time
- Policy page: "Personalizează" button resets consent, reopens modal
- Full RO/RU translation for all categories and UI strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 20:39:13 +00:00
Vasyka 68f58cbcdc feat: cookie policy & consent banner per Moldova law 195/2024
- Settings page: new "Politica Cookie" section — toggle banner on/off,
  editable RO/RU policy text (defaults to full 14-section template)
- Public route GET /cookie-policy → CookiePolicyController returns
  locale-aware full policy page with tenant branding
- Cookie banner partial (Alpine.js, localStorage) shown on tracking and
  landing pages; Accept/Reject stores consent, banner dismisses
- Translation keys added to lang/ro/portal.php and lang/ru/portal.php
- Alpine.js (CDN) added to tracking and landing pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 17:52:23 +00:00
Vasyka 753e81d292 fix: calendar board — 4 issues fixed
1. New form: Auto (vehicle) field first, client auto-filled via
   updatedNewApptVehicleId() when a vehicle is selected; getAllVehicleOptions()
   returns all vehicles as «plate — make model» for quick lookup

2. Scroll speed: JS intercepts wheel events on .cv-matrix-wrap and
   applies ×0.45 multiplier for horizontal scroll (shift+wheel or
   trackpad horizontal); scroll-behavior:smooth added to CSS

3. Event cards now show all 4 fields: 🔧 Lucrarea (title) +
   👤 Client + 🚗 Auto · plate + 🕐 Ora; each shown only if present

4. Delete bug fixed: replaced onclick=confirm() (races with wire:click)
   with wire:confirm= (Livewire native dialog — Cancel truly cancels)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-11 06:46:46 +00:00
Vasyka f885c04f90 fix: rework labor tree/list toggle — use Livewire wire:click not Alpine
Previous approach wrapped {{ $this->table }} in x-show which caused
Livewire/Alpine conflicts and broken toggling. New approach:
- viewMode is a Livewire #[Url] property (persisted in URL as ?view=)
- wire:click="setViewMode('list/tree')" triggers a clean Livewire render
- Only one view is rendered at a time (@if, not x-show)
- Tree: Alpine manages per-category open/close state client-side via
  indexed array; Open/Close all buttons use setAll() helper
- Visual overhaul: tighter cards, chevron arrows, proper hover states

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-11 05:08:53 +00:00
Vasyka 0180f29023 feat: tree/list view toggle for labor catalog
ListLabors now has a toggle button (persistat în localStorage) care
comută între:
- Listă: tabelul Filament grupat pe categorii (existent)
- Arbore: accordion cu toate categoriile ca noduri colapsabile,
  fiecare cu tabelul manoperelor sale (Manoperă, Mod, Ore, Preț,
  Cost, link Editează); butoane Deschide tot / Închide tot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-11 05:03:03 +00:00
Vasyka 5a3275c2cf fix: store opened_at and closed_at as DATETIME (not DATE)
DATE columns lose time information, causing timeline to show 00:00 for
car reception and WO closure. Migrated both columns to DATETIME and
updated: model casts (date → datetime), form pickers (DatePicker →
DateTimePicker with seconds:false), default to now() instead of today(),
and cleaned up defensive Carbon::parse() in getTimeline().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-11 04:52:03 +00:00
Vasyka ce9f082612 feat: per-row and global discount with margin validation
Parts (wo_parts) already had discount_pct; now also added to wo_works.
Labor catalog gets a cost_price field as the hard minimum sell floor.

- WorkOrderWork: discount_pct + cost_price fields; total calculation
  now includes discount: hours × price_per_hour × (1 − disc/100)
- Labor model: cost_price field (used to pre-fill work rows and validate)
- WorksRelationManager: discount_pct field with live helper text showing
  effective price + margin %; validation blocks save if below buy cost
  or below company min_discount_margin_pct; "Reducere globală" header
  action applies % to all work rows (skips rows that would breach limits)
- PartsRelationManager: same validation on existing discount_pct field;
  helper text (🟢/🟠/🔴) shows effective price + margin in real time;
  "Reducere globală" header action for all parts
- Settings page: new "Reduceri & limite preț" section with
  min_discount_margin_pct — 0 = no limit, >0 = hard block below that %
- LaborResource form: cost_price field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-11 04:48:15 +00:00
33 changed files with 1489 additions and 34 deletions
+2 -1
View File
@@ -26,7 +26,8 @@ COPY public ./public
RUN npm run build
# ─── Stage 3: Runtime (FrankenPHP + PHP 8.4) ──────────────────
FROM dunglas/frankenphp:1-php8.4 AS runtime
# Pinned to 1.13.4-php8.4.27 — fixes PHP2Shell RCE (CVE-2026-109618, CVE-2026-109619)
FROM dunglas/frankenphp:1.13.4-php8.4.27 AS runtime
WORKDIR /app
# Install PHP extensions Laravel needs
@@ -527,6 +527,23 @@ class CalendarBoard extends Page
return Vehicle::where('client_id', $clientId)->pluck('plate', 'id')->toArray();
}
public function getAllVehicleOptions(): array
{
return Vehicle::orderBy('plate')
->limit(300)
->get(['id', 'make', 'model', 'plate', 'client_id'])
->mapWithKeys(fn ($v) => [
$v->id => trim($v->plate . ' — ' . $v->make . ' ' . $v->model),
])->toArray();
}
public function updatedNewApptVehicleId($value): void
{
if ($value && $vehicle = Vehicle::find($value)) {
$this->newAppt['client_id'] = $vehicle->client_id;
}
}
private function calcHours(?string $start, ?string $end): float
{
if (! $start || ! $end) return 1.0;
+48
View File
@@ -69,6 +69,7 @@ class Settings extends Page
'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),
'min_discount_margin_pct' => $settings['min_discount_margin_pct'] ?? 0,
'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),
@@ -82,6 +83,9 @@ class Settings extends Page
'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']),
'cookie_banner_enabled' => data_get($settings, 'cookie.banner_enabled', true),
'cookie_policy_ro' => data_get($settings, 'cookie.policy_ro', ''),
'cookie_policy_ru' => data_get($settings, 'cookie.policy_ru', ''),
]);
}
@@ -123,6 +127,21 @@ class Settings extends Page
->schema([
Forms\Components\TextInput::make('labor_rate')->label(__('Tarif normo-oră'))->numeric()->required(),
]),
Schemas\Components\Section::make(__('Reduceri & limite preț'))
->description(__('Setează marja minimă permisă la vânzare. Sub această limită formularul blochează salvarea.'))
->columns(2)
->schema([
Forms\Components\TextInput::make('min_discount_margin_pct')
->label(__('Marjă minimă vânzare (%)'))
->numeric()
->step(0.1)
->minValue(0)
->maxValue(99)
->suffix('%')
->placeholder('Ex: 10')
->default(0)
->helperText(__('0 = fără limită. Ex: 10 = marja minimă 10%, adică nu poți da reducere care aduce marja sub 10%.')),
]),
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)
@@ -273,6 +292,28 @@ class Settings extends Page
->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['gemini'])
->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini']),
]),
Schemas\Components\Section::make(__('Politica Cookie'))
->description(__('Text afișat clienților conform Legii nr. 195/2024 (Moldova). Lasă gol pentru a folosi textul implicit. Folosiți [site], [email], [phone] ca variabile dinamice.'))
->columns(1)
->schema([
Forms\Components\Toggle::make('cookie_banner_enabled')
->label(__('Afișează banner cookie pe paginile publice'))
->helperText(__('Banner apare la prima vizită și poate fi acceptat/refuzat. Consimțământul se stochează în browser.'))
->default(true)
->columnSpanFull(),
Forms\Components\Textarea::make('cookie_policy_ro')
->label(__('Text politică cookie (RO)'))
->rows(15)
->placeholder(__('Lasă gol pentru textul implicit conform Legii 195/2024...'))
->helperText(__('Disponibil la /cookie-policy. Variabile: [site], [email], [phone]'))
->columnSpanFull(),
Forms\Components\Textarea::make('cookie_policy_ru')
->label(__('Text politică cookie (RU)'))
->rows(15)
->placeholder(__('Оставьте пустым для использования текста по умолчанию...'))
->helperText(__('Variabile: [site], [email], [phone]'))
->columnSpanFull(),
]),
])
->statePath('data');
}
@@ -315,6 +356,8 @@ class Settings extends Page
'after_days' => (int) ($data['reminder_after_days'] ?? 365),
'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30),
],
'min_discount_margin_pct' => isset($data['min_discount_margin_pct']) && $data['min_discount_margin_pct'] !== ''
? (float) $data['min_discount_margin_pct'] : 0,
'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),
@@ -335,6 +378,11 @@ class Settings extends Page
'gemini' => $data['ai_model_gemini'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini'],
],
],
'cookie' => [
'banner_enabled' => (bool) ($data['cookie_banner_enabled'] ?? true),
'policy_ro' => trim((string) ($data['cookie_policy_ro'] ?? '')) ?: null,
'policy_ru' => trim((string) ($data['cookie_policy_ru'] ?? '')) ?: null,
],
]),
]);
@@ -122,7 +122,7 @@ class WorkOrderDashboard extends Page
'icon' => '📋',
];
}
if ($wo->opened_at && $wo->opened_at->ne($wo->created_at)) {
if ($wo->opened_at && $wo->opened_at->format('Y-m-d H:i') !== $wo->created_at?->format('Y-m-d H:i')) {
$events[] = [
'at' => $wo->opened_at,
'kind' => 'opened',
@@ -142,7 +142,7 @@ class WorkOrderDashboard extends Page
}
if ($wo->closed_at) {
$events[] = [
'at' => $wo->closed_at instanceof \Carbon\Carbon ? $wo->closed_at : \Carbon\Carbon::parse($wo->closed_at),
'at' => $wo->closed_at,
'kind' => 'closed',
'label' => __('Fișă închisă'),
'meta' => null,
@@ -71,6 +71,8 @@ class LaborResource extends Resource
Forms\Components\TextInput::make('fixed_price')->label(__('Preț fix (MDL)'))->numeric()->default(0)
->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') === 'fixed'),
Forms\Components\TextInput::make('price')->label(__('Preț orientativ (MDL)'))->numeric()->default(0),
Forms\Components\TextInput::make('cost_price')->label(__('Preț cost (MDL)'))->numeric()->default(0)
->helperText(__('Costul intern — limita minimă sub care nu se poate vinde cu reducere.')),
Forms\Components\Toggle::make('is_active')->label(__('Activă'))->default(true),
]),
Forms\Components\Textarea::make('notes')->label(__('Observații'))->columnSpanFull()->rows(2),
@@ -3,12 +3,40 @@
namespace App\Filament\Tenant\Resources\LaborResource\Pages;
use App\Filament\Tenant\Resources\LaborResource;
use App\Models\Tenant\Labor;
use App\Models\Tenant\LaborCategory;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Livewire\Attributes\Url;
class ListLabors extends ListRecords
{
protected static string $resource = LaborResource::class;
protected function getHeaderActions(): array { return [Actions\CreateAction::make()]; }
protected string $view = 'filament.tenant.resources.labor.list-labors';
#[Url(as: 'view')]
public string $viewMode = 'list';
public function setViewMode(string $mode): void
{
$this->viewMode = in_array($mode, ['list', 'tree']) ? $mode : 'list';
}
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
public function getLaborTree(): \Illuminate\Support\Collection
{
$cats = LaborCategory::orderBy('sort_order')->get();
$labors = Labor::orderBy('name_ro')->get()->groupBy('category');
return $cats->map(fn ($cat) => (object) [
'label' => $cat->label(),
'name' => $cat->name,
'items' => $labors->get($cat->name, collect()),
])->filter(fn ($c) => $c->items->isNotEmpty())->values();
}
}
@@ -13,6 +13,8 @@ use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Unique;
/**
* Tehnicieni — vedere filtrată peste users (role=mechanic).
@@ -61,8 +63,28 @@ class MasterResource extends Resource
->columns(2)
->schema([
Forms\Components\TextInput::make('name')->label(__('Nume'))->required()->maxLength(120),
Forms\Components\TextInput::make('phone')->label(__('Telefon'))->tel()->maxLength(40),
Forms\Components\TextInput::make('email')->label(__('Email'))->email()->maxLength(120),
Forms\Components\TextInput::make('phone')
->label(__('Telefon'))
->tel()
->maxLength(40)
->unique(
table: 'users',
column: 'phone',
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule) => $rule->where('company_id', auth()->user()?->company_id),
)
->validationMessages(['unique' => __('Acest număr de telefon este deja folosit de un alt utilizator.')]),
Forms\Components\TextInput::make('email')
->label(__('Email'))
->email()
->maxLength(120)
->unique(
table: 'users',
column: 'email',
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule) => $rule->where('company_id', auth()->user()?->company_id),
)
->validationMessages(['unique' => __('Acest email este deja folosit de un alt utilizator.')]),
Forms\Components\Select::make('status')
->options(['active' => __('Activ'), 'inactive' => __('Inactiv'), 'blocked' => __('Blocat')])
->default('active')
@@ -4,11 +4,28 @@ namespace App\Filament\Tenant\Resources\MasterResource\Pages;
use App\Filament\Tenant\Resources\MasterResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
class CreateMaster extends CreateRecord
{
protected static string $resource = MasterResource::class;
protected function handleRecordCreation(array $data): Model
{
try {
return parent::handleRecordCreation($data);
} catch (UniqueConstraintViolationException $e) {
$msg = str_contains($e->getMessage(), 'email')
? __('Acest email este deja folosit de un alt utilizator.')
: __('Acest număr de telefon este deja folosit de un alt utilizator.');
Notification::make()->danger()->title($msg)->send();
throw new Halt();
}
}
}
@@ -4,11 +4,30 @@ namespace App\Filament\Tenant\Resources\MasterResource\Pages;
use App\Filament\Tenant\Resources\MasterResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
class EditMaster extends EditRecord
{
protected static string $resource = MasterResource::class;
protected function getHeaderActions(): array { return [Actions\DeleteAction::make()]; }
protected function handleRecordUpdate(Model $record, array $data): Model
{
try {
return parent::handleRecordUpdate($record, $data);
} catch (UniqueConstraintViolationException $e) {
$msg = str_contains($e->getMessage(), 'email')
? __('Acest email este deja folosit de un alt utilizator.')
: __('Acest număr de telefon este deja folosit de un alt utilizator.');
Notification::make()->danger()->title($msg)->send();
throw new Halt();
}
}
}
+23 -2
View File
@@ -13,6 +13,8 @@ use Filament\Schemas;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Unique;
class UserResource extends Resource
{
@@ -68,8 +70,27 @@ class UserResource extends Resource
->columns(2)
->schema([
Forms\Components\TextInput::make('name')->label(__('Nume'))->required()->maxLength(120),
Forms\Components\TextInput::make('email')->email()->required()->maxLength(120),
Forms\Components\TextInput::make('phone')->tel()->maxLength(40),
Forms\Components\TextInput::make('email')
->email()
->required()
->maxLength(120)
->unique(
table: 'users',
column: 'email',
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule) => $rule->where('company_id', auth()->user()?->company_id),
)
->validationMessages(['unique' => __('Acest email este deja folosit de un alt utilizator.')]),
Forms\Components\TextInput::make('phone')
->tel()
->maxLength(40)
->unique(
table: 'users',
column: 'phone',
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule) => $rule->where('company_id', auth()->user()?->company_id),
)
->validationMessages(['unique' => __('Acest număr de telefon este deja folosit de un alt utilizator.')]),
Forms\Components\Select::make('locale')
->options(['ro' => __('Română'), 'ru' => 'Русский', 'en' => 'English'])
->default('ro'),
@@ -3,7 +3,11 @@
namespace App\Filament\Tenant\Resources\UserResource\Pages;
use App\Filament\Tenant\Resources\UserResource;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
class CreateUser extends CreateRecord
{
@@ -12,6 +16,19 @@ class CreateUser extends CreateRecord
/** @var array<string> */
protected array $_rolesPicked = [];
protected function handleRecordCreation(array $data): Model
{
try {
return parent::handleRecordCreation($data);
} catch (UniqueConstraintViolationException $e) {
$msg = str_contains($e->getMessage(), 'email')
? __('Acest email este deja folosit de un alt utilizator.')
: __('Acest număr de telefon este deja folosit de un alt utilizator.');
Notification::make()->danger()->title($msg)->send();
throw new Halt();
}
}
protected function mutateFormDataBeforeCreate(array $data): array
{
$this->_rolesPicked = $data['roles_picked'] ?? [];
@@ -4,7 +4,11 @@ namespace App\Filament\Tenant\Resources\UserResource\Pages;
use App\Filament\Tenant\Resources\UserResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
class EditUser extends EditRecord
{
@@ -18,6 +22,19 @@ class EditUser extends EditRecord
/** @var array<string> */
protected array $_rolesPicked = [];
protected function handleRecordUpdate(Model $record, array $data): Model
{
try {
return parent::handleRecordUpdate($record, $data);
} catch (UniqueConstraintViolationException $e) {
$msg = str_contains($e->getMessage(), 'email')
? __('Acest email este deja folosit de un alt utilizator.')
: __('Acest număr de telefon este deja folosit de un alt utilizator.');
Notification::make()->danger()->title($msg)->send();
throw new Halt();
}
}
protected function mutateFormDataBeforeSave(array $data): array
{
$this->_rolesPicked = $data['roles_picked'] ?? [];
@@ -80,7 +80,7 @@ class WorkOrderResource extends Resource
->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\DateTimePicker::make('opened_at')->label(__('Deschis'))->default(now())->seconds(false)->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(),
@@ -192,7 +192,7 @@ class WorkOrderResource extends Resource
'style' => 'font-weight:600;color:' . ($record && $record->paidAmount() >= (float)$record->total ? '#16a34a' : '#dc2626'),
]),
Forms\Components\Toggle::make('approved')->label(__('Aprobat de client')),
Forms\Components\DatePicker::make('closed_at')->label(__('Închis')),
Forms\Components\DateTimePicker::make('closed_at')->label(__('Închis'))->seconds(false),
]),
]);
}
@@ -6,10 +6,12 @@ use App\Models\Tenant\Part;
use App\Models\Tenant\PartReservation;
use App\Models\Tenant\WorkOrderPart;
use App\Services\Warehouse\WarehouseService;
use App\Tenancy\TenantManager;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
@@ -26,6 +28,12 @@ class PartsRelationManager extends RelationManager
return __('Piese');
}
private static function minMargin(): float
{
$company = app(TenantManager::class)->current();
return (float) data_get($company?->settings, 'min_discount_margin_pct', 0);
}
public function form(Schema $schema): Schema
{
return $schema->components([
@@ -55,7 +63,55 @@ class PartsRelationManager extends RelationManager
Forms\Components\Select::make('unit_id')->label(__('UM'))->options(\App\Models\Tenant\Unit::forSelect())->searchable()->default(fn () => \App\Models\Tenant\Unit::where('code','buc')->value('id'))->default('buc'),
Forms\Components\TextInput::make('buy_price')->label(__('Preț achiziție'))->numeric()->default(0),
Forms\Components\TextInput::make('sell_price')->label(__('Preț vânzare'))->numeric()->required(),
Forms\Components\TextInput::make('discount_pct')->label(__('Discount %'))->numeric()->default(0),
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) {
$sellPrice = max(0, (float) $get('sell_price'));
$buyPrice = max(0, (float) $get('buy_price'));
$disc = min(100, max(0, (float) $value));
$effective = round($sellPrice * (1 - $disc / 100), 2);
if ($buyPrice > 0 && $effective < $buyPrice) {
$fail(__('Prețul efectiv (:eff MDL) este sub prețul de achiziție (:buy MDL).', [
'eff' => number_format($effective, 2),
'buy' => number_format($buyPrice, 2),
]));
return;
}
$minMargin = self::minMargin();
if ($minMargin > 0 && $sellPrice > 0 && $buyPrice > 0 && $effective > 0) {
$margin = ($effective - $buyPrice) / $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) {
$sellPrice = max(0, (float) $get('sell_price'));
$buyPrice = max(0, (float) $get('buy_price'));
$disc = min(100, max(0, (float) $state));
if ($sellPrice <= 0) return null;
$effective = round($sellPrice * (1 - $disc / 100), 2);
$minMargin = self::minMargin();
if ($buyPrice > 0) {
$margin = $effective > 0 ? ($effective - $buyPrice) / $effective * 100 : 0;
$color = ($effective < $buyPrice) ? '🔴' : (($minMargin > 0 && $margin < $minMargin) ? '🟠' : '🟢');
return "{$color} " . __('Efectiv:') . ' ' . number_format($effective, 2) . ' MDL · ' . __('Marjă:') . ' ' . number_format($margin, 1) . '%';
}
return __('Efectiv:') . ' ' . number_format($effective, 2) . ' MDL';
}),
Forms\Components\Select::make('status')
->options(\App\Support\I18n::opts(WorkOrderPart::STATUSES))
->default('needed')
@@ -75,6 +131,9 @@ class PartsRelationManager extends RelationManager
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
Tables\Columns\TextColumn::make('qty')->label(__('Cant.'))->alignRight(),
Tables\Columns\TextColumn::make('sell_price')->label(__('Preț'))->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('discount_pct')->label(__('Disc.'))->alignRight()
->formatStateUsing(fn ($state) => $state > 0 ? number_format((float)$state, 1) . '%' : '—')
->color(fn ($state) => $state > 0 ? 'warning' : null),
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
Tables\Columns\TextColumn::make('status')
->formatStateUsing(fn ($s) => __(WorkOrderPart::STATUSES[$s] ?? $s))
@@ -90,6 +149,49 @@ class PartsRelationManager extends RelationManager
Actions\CreateAction::make()
->label(__('Adaugă piesă'))
->modalHeading(__('Adaugă piesă în fișă')),
Actions\Action::make('bulk_discount_parts')
->label(__('Reducere globală'))
->icon('heroicon-m-tag')
->color('warning')
->schema([
Forms\Components\TextInput::make('discount_pct')
->label(__('Reducere % (toate piesele)'))
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->required()
->helperText(__('Aplică același % de reducere la toate piesele din această fișă.')),
])
->action(function (array $data) {
$disc = min(100, max(0, (float) $data['discount_pct']));
$minMargin = self::minMargin();
$blocked = 0;
foreach ($this->getOwnerRecord()->parts as $part) {
$sell = (float) $part->sell_price;
$buy = (float) $part->buy_price;
$effective = round($sell * (1 - $disc / 100), 2);
if ($buy > 0 && $effective < $buy) { $blocked++; continue; }
if ($minMargin > 0 && $sell > 0 && $buy > 0 && $effective > 0) {
$margin = ($effective - $buy) / $effective * 100;
if ($margin < $minMargin) { $blocked++; continue; }
}
$part->update(['discount_pct' => $disc]);
}
$msg = $blocked > 0
? __(':n piese blocate (sub limita de marjă sau preț achiziție).', ['n' => $blocked])
: null;
Notification::make()
->title(__('Reducere aplicată'))
->body($msg)
->{$blocked > 0 ? 'warning' : 'success'}()
->send();
}),
])
->actions([
Actions\Action::make('smart_price')
@@ -5,9 +5,11 @@ namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
use App\Models\Tenant\Labor;
use App\Models\Tenant\User;
use App\Models\Tenant\WorkOrderWork;
use App\Tenancy\TenantManager;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
@@ -24,6 +26,12 @@ class WorksRelationManager extends RelationManager
return __('Manopere');
}
private static function minMargin(): float
{
$company = app(TenantManager::class)->current();
return (float) data_get($company?->settings, 'min_discount_margin_pct', 0);
}
public function form(Schema $schema): Schema
{
return $schema->components([
@@ -40,6 +48,8 @@ class WorksRelationManager extends RelationManager
$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([
@@ -49,15 +59,77 @@ class WorksRelationManager extends RelationManager
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(),
Forms\Components\TextInput::make('price_per_hour')->label(__('Preț/h'))->numeric()->required(),
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'))
@@ -70,13 +142,12 @@ class WorksRelationManager extends RelationManager
]);
}
/** Global visibility flag from tenant Settings — user can hide margin details in-session. */
private static function marginDetailsVisible(): bool
{
if (! auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN)) {
return false;
}
$tenant = app(\App\Tenancy\TenantManager::class)->current();
$tenant = app(TenantManager::class)->current();
return (bool) data_get($tenant?->settings, 'show_internal_margin_details', true);
}
@@ -89,13 +160,15 @@ class WorksRelationManager extends RelationManager
->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;
// WO-level toggle off → hide margin text complet
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'), '.') . '%';
@@ -111,7 +184,6 @@ class WorksRelationManager extends RelationManager
->label(__('Adaugă manoperă'))
->modalHeading(__('Adaugă manoperă în fișă'))
->after(function (WorkOrderWork $record) {
// Auto-add the labor's default parts to the parent WO.
if (! $record->labor_id) return;
$labor = Labor::with('laborParts.part')->find($record->labor_id);
$wo = $record->workOrder;
@@ -126,6 +198,49 @@ class WorksRelationManager extends RelationManager
->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(),
@@ -0,0 +1,381 @@
<?php
namespace App\Http\Controllers;
use App\Tenancy\TenantManager;
use Illuminate\Http\Request;
class CookiePolicyController extends Controller
{
public function show(Request $request)
{
$tenant = app(TenantManager::class)->current();
$settings = (array) ($tenant?->settings ?? []);
$locale = session('locale', data_get($settings, 'language', 'ro'));
$policyKey = $locale === 'ru' ? 'policy_ru' : 'policy_ro';
$rawText = data_get($settings, "cookie.{$policyKey}")
?: ($locale === 'ru' ? self::defaultRu() : self::defaultRo());
$policyText = $this->replacePlaceholders($rawText, $tenant);
return view('public.cookie-policy', [
'tenant' => $tenant,
'policyText' => $policyText,
'locale' => $locale,
'themeColor' => data_get($settings, 'theme_color', '#3B82F6'),
'logoUrl' => method_exists($tenant ?? new \stdClass, 'getLogoUrl') ? $tenant?->getLogoUrl() : null,
]);
}
private function replacePlaceholders(string $text, $tenant): string
{
return str_replace(
['[site]', '[email]', '[phone]'],
[request()->getHost(), $tenant?->email ?? '', $tenant?->phone ?? ''],
$text
);
}
public static function defaultRo(): string
{
return <<<'EOT'
POLITICA DE UTILIZARE A FIȘIERELOR COOKIE
1. DISPOZIȚII GENERALE
1.1. Prezenta Politică de utilizare a fișierelor cookie (denumită în continuare – „Politica cookie") reglementează modul de utilizare a fișierelor cookie și a tehnologiilor similare pe site-ul web [site] (denumit în continuare – „Site-ul").
1.2. Politica cookie se aplică în conformitate cu legislația aplicabilă a Republicii Moldova, inclusiv prevederile Legii nr. 195/2024 privind protecția datelor cu caracter personal, Legii nr. 284/2004 privind comerțul electronic și ale altor acte normative aplicabile care reglementează prelucrarea datelor cu caracter personal și utilizarea tehnologiilor de stocare sau accesare a informațiilor pe dispozitivele utilizatorilor.
1.3. Prin utilizarea Site-ului, Utilizatorul poate primi informații privind utilizarea fișierelor cookie prin intermediul unei notificări corespunzătoare sau al mecanismului de gestionare a fișierelor cookie disponibil pe Site.
1.4. Fișierele cookie a căror utilizare este necesară pentru funcționarea tehnică a Site-ului sau pentru furnizarea serviciului solicitat de Utilizator pot fi utilizate fără obținerea unui consimțământ separat al Utilizatorului, în cazurile prevăzute de legislația aplicabilă.
1.5. Utilizarea fișierelor cookie care nu sunt strict necesare, inclusiv a fișierelor cookie analitice și de marketing, se efectuează în baza unui temei juridic corespunzător, inclusiv a consimțământului Utilizatorului, în cazul în care un astfel de consimțământ este impus de legislația aplicabilă.
2. CE SUNT FIȘIERELE COOKIE
2.1. Un fișier cookie reprezintă un fișier text de dimensiuni reduse, care conține anumite informații și care este stocat pe computerul, dispozitivul mobil sau un alt dispozitiv al Utilizatorului la accesarea unui site web.
2.2. Fișierele cookie permit site-ului web să recunoască dispozitivul Utilizatorului, să păstreze anumite setări și să asigure funcționarea corespunzătoare a anumitor funcții ale Site-ului.
2.3. Fișierele cookie pot fi utilizate atât de Site, cât și de terți ale căror servicii sunt utilizate pe Site.
2.4. Fișierele cookie nu sunt, în sine, viruși și, de regulă, nu conțin software care poate fi executat în mod autonom pe dispozitivul Utilizatorului.
2.5. În funcție de tipul și scopul acestora, fișierele cookie pot conține informații care nu permit identificarea directă a Utilizatorului, însă, în anumite cazuri, informațiile asociate fișierelor cookie pot fi considerate date cu caracter personal în conformitate cu legislația aplicabilă.
3. CATEGORIILE FIȘIERELOR COOKIE
3.1. În funcție de perioada de stocare, fișierele cookie pot fi:
– fișiere cookie de sesiune, care sunt utilizate pe durata sesiunii curente de accesare a Site-ului și, de regulă, sunt șterse după închiderea browserului;
– fișiere cookie persistente, care sunt păstrate pe dispozitivul Utilizatorului pentru o anumită perioadă de timp sau până la momentul ștergerii acestora de către Utilizator.
3.2. În funcție de origine, fișierele cookie pot fi:
– fișiere cookie proprii, care sunt instalate direct de Site;
– fișiere cookie ale terților, care sunt instalate de terți sau de serviciile acestora utilizate pe Site.
3.3. În funcție de scop, Site-ul poate utiliza următoarele categorii de fișiere cookie:
– fișiere cookie strict necesare sau tehnice;
– fișiere cookie funcționale;
– fișiere cookie analitice sau statistice;
– fișiere cookie de marketing sau publicitare.
4. FIȘIERE COOKIE STRICT NECESARE
4.1. Fișierele cookie strict necesare sunt necesare pentru asigurarea funcționării corecte și sigure a Site-ului și a funcțiilor sale principale.
4.2. Astfel de fișiere cookie pot fi utilizate, în special, pentru:
– asigurarea funcționării corecte a Site-ului;
– asigurarea securității Site-ului;
– păstrarea informațiilor necesare pentru funcționarea anumitor funcții;
– gestionarea sesiunii Utilizatorului;
– asigurarea accesului la anumite funcții ale contului personal sau ale contului de utilizator.
4.3. Dezactivarea fișierelor cookie strict necesare poate duce la imposibilitatea utilizării anumitor funcții ale Site-ului sau la funcționarea necorespunzătoare a Site-ului.
5. FIȘIERE COOKIE FUNCȚIONALE
5.1. Fișierele cookie funcționale permit Site-ului să rețină alegerile și setările Utilizatorului în scopul asigurării unei utilizări mai confortabile a Site-ului.
5.2. Astfel de fișiere cookie pot fi utilizate, în special, pentru păstrarea:
– limbii selectate;
– setărilor de afișare a Site-ului;
– anumitor preferințe ale Utilizatorului;
– altor setări necesare pentru îmbunătățirea experienței de utilizare.
5.3. Utilizarea fișierelor cookie funcționale se efectuează în conformitate cu cerințele aplicabile ale legislației și cu setările fișierelor cookie selectate de Utilizator.
6. FIȘIERE COOKIE ANALITICE ȘI STATISTICE
6.1. Fișierele cookie analitice și statistice sunt utilizate pentru obținerea de informații privind modul în care Utilizatorii interacționează cu Site-ul.
6.2. Astfel de fișiere cookie pot fi utilizate pentru analiza:
– numărului de vizite pe Site;
– paginilor vizitate;
– surselor din care Utilizatorii accesează Site-ul;
– duratei vizitei;
– interacțiunii Utilizatorilor cu anumite funcții ale Site-ului.
6.3. Informațiile analitice obținute sunt utilizate pentru analiza traficului, îmbunătățirea structurii, funcționalității și conținutului Site-ului, precum și pentru îmbunătățirea calității serviciilor oferite Utilizatorilor.
6.4. Site-ul poate utiliza servicii analitice furnizate de terți, inclusiv Google Analytics, în conformitate cu condițiile serviciilor respective și cu legislația aplicabilă.
6.5. Fișierele cookie analitice sunt utilizate în baza unui temei juridic corespunzător, inclusiv a consimțământului Utilizatorului, în cazul în care un astfel de consimțământ este impus de legislație.
7. FIȘIERE COOKIE DE MARKETING ȘI PUBLICITARE
7.1. Fișierele cookie de marketing și publicitare pot fi utilizate pentru analiza intereselor Utilizatorului, evaluarea eficienței campaniilor publicitare și furnizarea unor reclame care pot fi mai relevante pentru interesele Utilizatorului.
7.2. Astfel de fișiere cookie pot fi utilizate atât de Site, cât și de terți care furnizează servicii de publicitate sau analiză.
7.3. Fișierele cookie de marketing și publicitare pot fi utilizate numai în baza unui temei juridic corespunzător, inclusiv a consimțământului Utilizatorului, în cazul în care un astfel de consimțământ este impus de legislația aplicabilă.
7.4. Utilizatorul are dreptul să modifice în orice moment setările selectate anterior și să își retragă consimțământul privind utilizarea categoriilor corespunzătoare de fișiere cookie.
8. FIȘIERE COOKIE ALE TERȚILOR
8.1. Site-ul poate utiliza servicii furnizate de terți care, în cadrul prestării serviciilor respective, pot instala propriile fișiere cookie sau pot accesa informațiile conținute în astfel de fișiere.
8.2. Astfel de servicii pot include servicii analitice, publicitare, tehnice și alte servicii necesare pentru funcționarea și dezvoltarea Site-ului.
8.3. Utilizarea fișierelor cookie ale terților se efectuează în conformitate cu condițiile stabilite de terții respectivi și cu legislația aplicabilă.
8.4. Operatorul nu exercită un control direct asupra tuturor tehnologiilor și fișierelor cookie utilizate de terți independenți. Utilizatorului i se recomandă să consulte politicile de confidențialitate și politicile cookie ale serviciilor respective.
9. CONSIMȚĂMÂNTUL PRIVIND UTILIZAREA FIȘIERELOR COOKIE
9.1. La prima vizitare a Site-ului, Utilizatorului i se poate oferi posibilitatea de a consulta informațiile privind utilizarea fișierelor cookie și de a selecta setările corespunzătoare.
9.2. Utilizatorul își poate exprima consimțământul pentru utilizarea anumitor categorii de fișiere cookie care nu sunt strict necesare pentru funcționarea Site-ului.
9.3. Consimțământul Utilizatorului trebuie să fie liber exprimat, informat și exprimat printr-o acțiune activă corespunzătoare, în cazul în care această formă de consimțământ este impusă de legislația aplicabilă.
9.4. Lipsa consimțământului pentru utilizarea fișierelor cookie opționale nu trebuie să împiedice Utilizatorul să utilizeze funcțiile principale ale Site-ului, cu excepția cazurilor în care o anumită funcție necesită în mod obiectiv utilizarea tehnologiilor corespunzătoare.
9.5. Utilizatorul are dreptul să își modifice în orice moment setările sau să își retragă consimțământul acordat anterior, prin intermediul mecanismului de gestionare a fișierelor cookie disponibil pe Site, în cazul în care un astfel de mecanism este prevăzut, precum și prin intermediul setărilor browserului.
10. GESTIONAREA FIȘIERELOR COOKIE
10.1. Utilizatorul poate gestiona în mod independent utilizarea fișierelor cookie prin intermediul setărilor browserului său de internet.
10.2. În funcție de browserul utilizat, Utilizatorul poate:
– permite utilizarea tuturor fișierelor cookie;
– limita utilizarea anumitor fișiere cookie;
– bloca utilizarea fișierelor cookie;
– șterge fișierele cookie salvate anterior;
– configura ștergerea automată a fișierelor cookie după încheierea sesiunii de utilizare a browserului.
10.3. Setările pentru gestionarea fișierelor cookie sunt, de regulă, disponibile în secțiunile „Setări", „Opțiuni", „Confidențialitate" sau în secțiuni similare ale browserului de internet utilizat.
10.4. Limitarea sau dezactivarea completă a fișierelor cookie poate afecta funcționalitatea Site-ului și poate determina funcționarea necorespunzătoare sau indisponibilitatea anumitor funcții ori servicii.
11. PERIOADA DE STOCARE A FIȘIERELOR COOKIE
11.1. Perioada de stocare a fișierelor cookie depinde de tipul, scopul și parametrii tehnici ai acestora.
11.2. Fișierele cookie de sesiune sunt, de regulă, șterse după încheierea sesiunii de utilizare sau după închiderea browserului.
11.3. Fișierele cookie persistente sunt păstrate pe durata stabilită pentru fișierul cookie respectiv sau până la momentul ștergerii acestora de către Utilizator prin intermediul setărilor browserului sau al unui alt mecanism de gestionare disponibil.
11.4. Operatorul urmărește să utilizeze fișierele cookie numai pe perioada necesară pentru atingerea scopurilor pentru care acestea sunt utilizate, ținând cont de cerințele legislației aplicabile.
12. SECURITATEA ȘI CONFIDENȚIALITATEA
12.1. Fișierele cookie nu sunt programe și nu pot executa în mod autonom cod software pe dispozitivul Utilizatorului.
12.2. În anumite cazuri, informațiile asociate fișierelor cookie pot fi utilizate împreună cu alte date și pot intra sub incidența legislației privind protecția datelor cu caracter personal.
12.3. În cazul prelucrării datelor cu caracter personal asociate utilizării fișierelor cookie, Operatorul întreprinde măsurile organizatorice și tehnice corespunzătoare pentru asigurarea securității acestor date, în conformitate cu legislația aplicabilă.
12.4. Utilizatorului i se recomandă să utilizeze versiuni actualizate ale browserelor și software-ului, precum și mijloacele disponibile pentru protecția dispozitivului și a conexiunii la internet.
13. MODIFICĂRI ALE POLITICII COOKIE
13.1. Operatorul are dreptul să modifice prezenta Politică cookie în legătură cu modificările legislației, tehnologiilor utilizate, funcționalității Site-ului sau modului de prelucrare a datelor.
13.2. Versiunea actualizată a Politicii cookie este publicată pe Site.
13.3. Modificările intră în vigoare din momentul publicării versiunii actualizate pe Site, cu excepția cazului în care un alt termen este prevăzut de versiunea în vigoare a Politicii cookie sau de legislația aplicabilă.
14. DATE DE CONTACT
14.1. Pentru aspecte legate de utilizarea fișierelor cookie, prelucrarea datelor cu caracter personal sau prezenta Politică cookie, Utilizatorul poate contacta operatorul Site-ului la următoarele date de contact:
Email: [email]
Telefon: [phone]
EOT;
}
public static function defaultRu(): string
{
return <<<'EOT'
ПОЛИТИКА ИСПОЛЬЗОВАНИЯ COOKIE-ФАЙЛОВ
1. ОБЩИЕ ПОЛОЖЕНИЯ
1.1. Настоящая Политика использования cookie-файлов (далее – «Политика cookie») регулирует порядок использования cookie-файлов и аналогичных технологий на веб-сайте [site] (далее – «Сайт»).
1.2. Политика cookie применяется в соответствии с применимым законодательством Республики Молдова, включая положения Закона № 195/2024 о защите персональных данных, Закона № 284/2004 об электронной торговле и иных применимых нормативных актов, регулирующих обработку персональных данных и использование технологий хранения или доступа к информации на устройствах пользователей.
1.3. Используя Сайт, Пользователь может получать информацию об использовании cookie-файлов через соответствующее уведомление или механизм управления cookie-файлами, размещённый на Сайте.
1.4. Cookie-файлы, обработка которых является необходимой для технического функционирования Сайта или предоставления запрошенной Пользователем услуги, могут использоваться без получения отдельного согласия Пользователя в случаях, предусмотренных применимым законодательством.
1.5. Использование cookie-файлов, не являющихся строго необходимыми, включая аналитические и маркетинговые cookie-файлы, осуществляется при наличии соответствующего правового основания, в том числе согласия Пользователя, если такое согласие требуется применимым законодательством.
2. ЧТО ТАКОЕ COOKIE-ФАЙЛЫ
2.1. Cookie-файл представляет собой небольшой текстовый файл, содержащий определённую информацию, который сохраняется на компьютере, мобильном устройстве или ином устройстве Пользователя при посещении веб-сайта.
2.2. Cookie-файлы позволяют веб-сайту распознавать устройство Пользователя, сохранять отдельные настройки и обеспечивать корректное функционирование определённых функций Сайта.
2.3. Cookie-файлы могут использоваться как самим Сайтом, так и третьими лицами, услуги которых используются на Сайте.
2.4. Cookie-файлы сами по себе не являются вирусами и, как правило, не содержат программного обеспечения, которое может самостоятельно выполняться на устройстве Пользователя.
2.5. В зависимости от типа и назначения cookie-файлы могут содержать информацию, которая прямо не идентифицирует Пользователя, однако в определённых случаях информация, связанная с cookie-файлами, может рассматриваться как персональные данные в соответствии с применимым законодательством.
3. КАТЕГОРИИ COOKIE-ФАЙЛОВ
3.1. В зависимости от срока хранения cookie-файлы могут быть:
– сессионными cookie-файлами, которые используются в течение текущего сеанса посещения Сайта и, как правило, удаляются после закрытия браузера;
– постоянными cookie-файлами, которые сохраняются на устройстве Пользователя в течение определённого периода времени или до момента их удаления Пользователем.
3.2. В зависимости от происхождения cookie-файлы могут быть:
– собственными cookie-файлами, которые устанавливаются непосредственно Сайтом;
– сторонними cookie-файлами, которые устанавливаются третьими лицами или их сервисами, используемыми на Сайте.
3.3. В зависимости от назначения Сайт может использовать следующие категории cookie-файлов:
– строго необходимые или технические cookie-файлы;
– функциональные cookie-файлы;
– аналитические или статистические cookie-файлы;
– маркетинговые или рекламные cookie-файлы.
4. СТРОГО НЕОБХОДИМЫЕ COOKIE-ФАЙЛЫ
4.1. Строго необходимые cookie-файлы требуются для обеспечения корректного и безопасного функционирования Сайта и его основных функций.
4.2. Такие cookie-файлы могут использоваться, в частности, для:
– обеспечения корректной работы Сайта;
– обеспечения безопасности Сайта;
– сохранения информации, необходимой для работы отдельных функций;
– управления сеансом Пользователя;
– обеспечения доступа к отдельным функциям личного кабинета или учётной записи.
4.3. Отключение строго необходимых cookie-файлов может привести к невозможности использования отдельных функций Сайта или к некорректной работе Сайта.
5. ФУНКЦИОНАЛЬНЫЕ COOKIE-ФАЙЛЫ
5.1. Функциональные cookie-файлы позволяют Сайту запоминать выбор и настройки Пользователя с целью обеспечения более удобного использования Сайта.
5.2. Такие cookie-файлы могут использоваться, в частности, для сохранения:
– выбранного языка;
– настроек отображения Сайта;
– отдельных пользовательских предпочтений;
– иных настроек, необходимых для улучшения пользовательского опыта.
5.3. Использование функциональных cookie-файлов осуществляется в соответствии с применимыми требованиями законодательства и выбранными Пользователем настройками cookie-файлов.
6. АНАЛИТИЧЕСКИЕ И СТАТИСТИЧЕСКИЕ COOKIE-ФАЙЛЫ
6.1. Аналитические и статистические cookie-файлы используются для получения информации о том, каким образом Пользователи взаимодействуют с Сайтом.
6.2. Такие cookie-файлы могут использоваться для анализа:
– количества посещений Сайта;
– посещаемых страниц;
– источников перехода на Сайт;
– продолжительности посещения;
– взаимодействия Пользователей с отдельными функциями Сайта.
6.3. Полученная аналитическая информация используется для анализа посещаемости, улучшения структуры, функциональности и содержания Сайта, а также для повышения качества предоставляемых Пользователям услуг.
6.4. Сайт может использовать аналитические сервисы третьих лиц, включая Google Analytics, в соответствии с условиями соответствующих сервисов и применимым законодательством.
6.5. Аналитические cookie-файлы используются при наличии соответствующего правового основания, включая согласие Пользователя, если такое согласие требуется законодательством.
7. МАРКЕТИНГОВЫЕ И РЕКЛАМНЫЕ COOKIE-ФАЙЛЫ
7.1. Маркетинговые и рекламные cookie-файлы могут использоваться для анализа интересов Пользователя, оценки эффективности рекламных кампаний и предоставления рекламы, которая может быть более релевантной интересам Пользователя.
7.2. Такие cookie-файлы могут использоваться как Сайтом, так и третьими лицами, предоставляющими рекламные или аналитические услуги.
7.3. Маркетинговые и рекламные cookie-файлы могут использоваться только при наличии соответствующего правового основания, включая согласие Пользователя, если такое согласие требуется применимым законодательством.
7.4. Пользователь вправе в любое время изменить ранее выбранные настройки и отозвать согласие на использование соответствующих категорий cookie-файлов.
8. СТОРОННИЕ COOKIE-ФАЙЛЫ
8.1. Сайт может использовать сервисы третьих лиц, которые в рамках предоставления своих услуг могут устанавливать собственные cookie-файлы или получать доступ к информации, содержащейся в таких файлах.
8.2. К таким сервисам могут относиться аналитические, рекламные, технические и иные сервисы, необходимые для функционирования и развития Сайта.
8.3. Использование сторонних cookie-файлов осуществляется в соответствии с условиями соответствующих третьих лиц и применимым законодательством.
8.4. Оператор не осуществляет непосредственный контроль над всеми технологиями и cookie-файлами, используемыми независимыми третьими лицами. Пользователю рекомендуется ознакомиться с политиками конфиденциальности и cookie-политиками соответствующих сервисов.
9. СОГЛАСИЕ НА ИСПОЛЬЗОВАНИЕ COOKIE-ФАЙЛОВ
9.1. При первом посещении Сайта Пользователю может быть предоставлена возможность ознакомиться с информацией об использовании cookie-файлов и выбрать соответствующие настройки.
9.2. Пользователь может предоставить согласие на использование отдельных категорий cookie-файлов, которые не являются строго необходимыми для функционирования Сайта.
9.3. Согласие Пользователя должно быть добровольным, информированным и выраженным посредством соответствующего активного действия, если такая форма согласия требуется применимым законодательством.
9.4. Отсутствие согласия на использование необязательных cookie-файлов не должно препятствовать Пользователю использовать основные функции Сайта, за исключением случаев, когда определённая функция объективно требует использования соответствующих технологий.
9.5. Пользователь вправе в любое время изменить свои настройки или отозвать ранее предоставленное согласие посредством доступного на Сайте механизма управления cookie-файлами, если такой механизм предусмотрен, а также посредством настроек браузера.
10. УПРАВЛЕНИЕ COOKIE-ФАЙЛАМИ
10.1. Пользователь может самостоятельно управлять использованием cookie-файлов посредством настроек своего интернет-браузера.
10.2. В зависимости от используемого браузера Пользователь может:
– разрешить использование всех cookie-файлов;
– ограничить использование определённых cookie-файлов;
– заблокировать использование cookie-файлов;
– удалить ранее сохранённые cookie-файлы;
– настроить автоматическое удаление cookie-файлов после завершения сеанса работы браузера.
10.3. Настройки управления cookie-файлами обычно доступны в разделах «Настройки», «Параметры», «Конфиденциальность» или аналогичных разделах используемого интернет-браузера.
10.4. Ограничение или полное отключение cookie-файлов может повлиять на функциональность Сайта и привести к тому, что отдельные функции или сервисы будут работать некорректно либо станут недоступными.
11. СРОК ХРАНЕНИЯ COOKIE-ФАЙЛОВ
11.1. Срок хранения cookie-файлов зависит от их типа, назначения и технических параметров.
11.2. Сессионные cookie-файлы, как правило, удаляются после завершения сеанса работы или закрытия браузера.
11.3. Постоянные cookie-файлы хранятся в течение срока, установленного для соответствующего cookie-файла, либо до момента их удаления Пользователем посредством настроек браузера или иного доступного механизма управления.
11.4. Оператор стремится использовать cookie-файлы только в течение периода, необходимого для достижения целей, для которых они используются, с учётом требований применимого законодательства.
12. БЕЗОПАСНОСТЬ И КОНФИДЕНЦИАЛЬНОСТЬ
12.1. Cookie-файлы не являются программами и не могут самостоятельно запускать программный код на устройстве Пользователя.
12.2. В отдельных случаях информация, связанная с cookie-файлами, может использоваться совместно с другими данными и подпадать под действие законодательства о защите персональных данных.
12.3. В случае обработки персональных данных, связанных с использованием cookie-файлов, Оператор принимает соответствующие организационные и технические меры для обеспечения безопасности таких данных в соответствии с применимым законодательством.
12.4. Пользователю рекомендуется использовать актуальные версии браузеров и программного обеспечения, а также применять доступные средства защиты устройства и интернет-соединения.
13. ИЗМЕНЕНИЯ В ПОЛИТИКЕ COOKIE-ФАЙЛОВ
13.1. Оператор вправе вносить изменения в настоящую Политику cookie в связи с изменениями законодательства, используемых технологий, функциональности Сайта или порядка обработки данных.
13.2. Актуальная редакция Политики cookie размещается на Сайте.
13.3. Изменения вступают в силу с момента публикации обновлённой редакции на Сайте, если иной срок не предусмотрен действующей редакцией Политики cookie или применимым законодательством.
14. КОНТАКТНАЯ ИНФОРМАЦИЯ
14.1. По вопросам, связанным с использованием cookie-файлов, обработкой персональных данных или настоящей Политикой cookie, Пользователь может обратиться к оператору сайта по следующим контактным данным:
Email: [email]
Телефон: [phone]
EOT;
}
}
+2 -1
View File
@@ -23,7 +23,7 @@ class Labor extends Model
protected $fillable = [
'company_id', 'category', 'name_ro', 'name_ru', 'name_en', 'code',
'hours', 'pricing_mode', 'fixed_price', 'price', 'is_active', 'notes',
'hours', 'pricing_mode', 'fixed_price', 'price', 'cost_price', 'is_active', 'notes',
];
/** Localised name — prefers name_{locale}, falls back to name_ro. */
@@ -38,6 +38,7 @@ class Labor extends Model
'hours' => 'decimal:2',
'fixed_price' => 'decimal:2',
'price' => 'decimal:2',
'cost_price' => 'decimal:2',
'is_active' => 'boolean',
];
+2 -2
View File
@@ -45,8 +45,8 @@ class WorkOrder extends Model implements HasMedia
];
protected $casts = [
'opened_at' => 'date',
'closed_at' => 'date',
'opened_at' => 'datetime',
'closed_at' => 'datetime',
'approved_at' => 'datetime',
'eta_at' => 'datetime',
'eta_promised' => 'datetime',
+6 -2
View File
@@ -41,7 +41,7 @@ class WorkOrderWork extends Model
protected $fillable = [
'company_id', 'work_order_id', 'labor_id', 'master_id',
'name', 'hours', 'price_per_hour', 'total', 'status', 'notes',
'name', 'hours', 'price_per_hour', 'discount_pct', 'cost_price', 'total', 'status', 'notes',
'requires_approval', 'approved_at', 'approval_token', 'declined_at',
'mechanic_status', 'mechanic_started_at', 'mechanic_done_at',
'actual_hours', 'paused_seconds_total', 'paused_at',
@@ -52,6 +52,8 @@ class WorkOrderWork extends Model
protected $casts = [
'hours' => 'decimal:2',
'price_per_hour' => 'decimal:2',
'discount_pct' => 'decimal:2',
'cost_price' => 'decimal:2',
'total' => 'decimal:2',
'requires_approval' => 'boolean',
'approved_at' => 'datetime',
@@ -173,7 +175,9 @@ class WorkOrderWork extends Model
protected static function booted(): void
{
static::saving(function (self $row) {
$row->total = round((float) $row->hours * (float) $row->price_per_hour, 2);
$gross = round((float) $row->hours * (float) $row->price_per_hour, 2);
$disc = min(100, max(0, (float) $row->discount_pct));
$row->total = round($gross * (1 - $disc / 100), 2);
if ($row->requires_approval && empty($row->approval_token)) {
$row->approval_token = \Illuminate\Support\Str::random(24);
}
@@ -86,6 +86,16 @@ class CentralPanelProvider extends PanelProvider
</script>
HTML
)
->renderHook(
PanelsRenderHook::BODY_END,
fn (): string => Blade::render(<<<'BLADE'
@php
$tenant = null;
$themeColor = '#6366f1';
@endphp
@include('partials.cookie-banner')
BLADE)
)
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
@@ -342,6 +342,16 @@ class TenantPanelProvider extends PanelProvider
</script>
@endif
BLADE)
)
->renderHook(
PanelsRenderHook::BODY_END,
fn (): string => Blade::render(<<<'BLADE'
@php
$tenant = app(\App\Tenancy\TenantManager::class)->current();
$themeColor = $tenant?->settings['theme_color'] ?? '#3B82F6';
@endphp
@include('partials.cookie-banner')
BLADE)
);
}
}
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('wo_works', function (Blueprint $table) {
$table->decimal('discount_pct', 5, 2)->default(0)->after('total');
$table->decimal('cost_price', 10, 2)->default(0)->after('discount_pct');
});
Schema::table('labors', function (Blueprint $table) {
$table->decimal('cost_price', 10, 2)->default(0)->after('price');
});
}
public function down(): void
{
Schema::table('wo_works', function (Blueprint $table) {
$table->dropColumn(['discount_pct', 'cost_price']);
});
Schema::table('labors', function (Blueprint $table) {
$table->dropColumn('cost_price');
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('work_orders', function (Blueprint $table) {
$table->dateTime('opened_at')->nullable()->change();
$table->dateTime('closed_at')->nullable()->change();
});
}
public function down(): void
{
Schema::table('work_orders', function (Blueprint $table) {
$table->date('opened_at')->nullable()->change();
$table->date('closed_at')->nullable()->change();
});
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Remove duplicate phones within the same company before adding constraint.
// Keep the oldest record (smallest id), null out the phone on duplicates.
DB::statement('
UPDATE users u1
JOIN users u2 ON u2.company_id = u1.company_id
AND u2.phone = u1.phone
AND u2.id < u1.id
SET u1.phone = NULL
WHERE u1.phone IS NOT NULL
');
Schema::table('users', function (Blueprint $table) {
// MySQL/MariaDB allows multiple NULLs in a UNIQUE index,
// so users without a phone are unaffected.
$table->unique(['company_id', 'phone'], 'users_company_id_phone_unique');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique('users_company_id_phone_unique');
});
}
};
+30
View File
@@ -153,4 +153,34 @@ return [
'reset_title' => 'Setează o parolă nouă',
'reset_button' => 'Setează parola',
],
'cookie' => [
'policy_title' => 'Politica de utilizare a fișierelor cookie',
'back' => 'Înapoi',
'manage_title' => 'Gestionează setările cookie',
// Banner (simple view)
'banner_title' => 'Utilizăm fișiere cookie',
'banner_body' => 'Utilizăm cookie-uri pentru funcționarea site-ului și îmbunătățirea experienței dvs. Cookie-urile strict necesare nu necesită consimțământ (Legea nr. 195/2024).',
'read_policy' => 'Politica cookie',
'accept' => 'Acceptă toate',
'reject' => 'Respinge opționale',
'customize' => 'Personalizează',
// Settings modal
'settings_title' => 'Setări cookie',
'settings_body' => 'Alegeți ce categorii de cookie-uri acceptați. Puteți modifica preferințele oricând.',
'save_settings' => 'Salvează preferințele',
'always_on' => 'Întotdeauna activ',
// Categories
'cat_necessary' => 'Cookie-uri strict necesare',
'cat_necessary_desc' => 'Indispensabile pentru funcționarea corectă a site-ului: autentificare, sesiune, securitate, preferințe limbă. Nu pot fi dezactivate.',
'cat_functional' => 'Cookie-uri funcționale',
'cat_functional_desc'=> 'Permit site-ului să rețină alegerile dvs. (limbă, preferințe de afișare) pentru o experiență mai bună.',
'cat_analytics' => 'Cookie-uri analitice',
'cat_analytics_desc' => 'Ne ajută să înțelegem cum utilizați site-ul (pagini vizitate, surse de trafic) pentru a îmbunătăți serviciile. Date anonimizate.',
'cat_marketing' => 'Cookie-uri de marketing',
'cat_marketing_desc' => 'Utilizate pentru publicitate relevantă și evaluarea eficienței campaniilor. Pot fi setate de terți (Google Ads etc.).',
],
];
+30
View File
@@ -153,4 +153,34 @@ return [
'reset_title' => 'Задать новый пароль',
'reset_button' => 'Установить пароль',
],
'cookie' => [
'policy_title' => 'Политика использования cookie-файлов',
'back' => 'Назад',
'manage_title' => 'Управление настройками cookie',
// Баннер (простой вид)
'banner_title' => 'Мы используем cookie',
'banner_body' => 'Мы используем cookie-файлы для работы сайта и улучшения вашего опыта. Строго необходимые cookie не требуют согласия (Закон № 195/2024).',
'read_policy' => 'Политика cookie',
'accept' => 'Принять все',
'reject' => 'Отклонить необязательные',
'customize' => 'Настроить',
// Модальное окно настроек
'settings_title' => 'Настройки cookie',
'settings_body' => 'Выберите, какие категории cookie-файлов вы принимаете. Вы можете изменить настройки в любое время.',
'save_settings' => 'Сохранить настройки',
'always_on' => 'Всегда активно',
// Категории
'cat_necessary' => 'Строго необходимые cookie',
'cat_necessary_desc' => 'Необходимы для корректной работы сайта: аутентификация, сессия, безопасность, языковые настройки. Не могут быть отключены.',
'cat_functional' => 'Функциональные cookie',
'cat_functional_desc'=> 'Позволяют сайту запоминать ваши предпочтения (язык, настройки отображения) для более удобного использования.',
'cat_analytics' => 'Аналитические cookie',
'cat_analytics_desc' => 'Помогают нам понять, как вы используете сайт (посещённые страницы, источники трафика), для улучшения сервиса. Данные анонимизированы.',
'cat_marketing' => 'Маркетинговые cookie',
'cat_marketing_desc' => 'Используются для релевантной рекламы и оценки эффективности кампаний. Могут устанавливаться третьими лицами (Google Ads и др.).',
],
];
@@ -86,7 +86,7 @@
.cv-notes strong { display:block; margin-bottom:4px; }
/* Matrix */
.cv-matrix-wrap { background:var(--cv-surface); border-radius:8px; padding:16px; box-shadow:var(--cv-shadow); overflow-x:auto; }
.cv-matrix-wrap { background:var(--cv-surface); border-radius:8px; padding:16px; box-shadow:var(--cv-shadow); overflow-x:auto; scroll-behavior:smooth; }
.cv-grid { display:grid; grid-template-columns:140px repeat(7, minmax(160px, 1fr)); gap:1px; background:var(--cv-border); border-radius:6px; overflow:hidden; min-width:1240px; }
.cv-cell { background:var(--cv-surface); padding:10px 8px; min-height:90px; position:relative; }
.cv-header-cell { background:var(--cv-row-bg); text-align:center; font-weight:600; font-size:13px; padding:10px; }
@@ -113,13 +113,14 @@
.cv-load.warn { background:var(--cv-orange-bg); color:var(--cv-orange-text); }
.cv-load.full { background:var(--cv-red-bg); color:var(--cv-red-text); }
.cv-event { background:#EBF5FF; border-left:3px solid var(--cv-blue); padding:6px 8px; margin-bottom:4px; border-radius:4px; cursor:grab; font-size:12px; transition:all .15s; }
.cv-event { background:#EBF5FF; border-left:3px solid var(--cv-blue); padding:5px 7px; margin-bottom:4px; border-radius:4px; cursor:grab; font-size:11px; transition:all .15s; line-height:1.35; }
.cv-event:hover { transform:translateY(-1px); box-shadow:0 2px 6px rgba(0,0,0,0.08); }
.cv-event:active { cursor:grabbing; }
.cv-event.dragging { opacity:0.4; }
.cv-event-title { font-weight:600; color:var(--cv-blue-text); }
.cv-event-meta { color:var(--cv-text-2); font-size:11px; margin-top:2px; }
.cv-event-time { font-weight:500; color:var(--cv-text); font-size:11px; margin-top:2px; }
.cv-event-title { font-weight:700; color:var(--cv-blue-text); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cv-event-client { color:var(--cv-text); font-size:11px; margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cv-event-vehicle { color:var(--cv-text-2); font-size:11px; margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cv-event-time { font-weight:600; color:var(--cv-text-3); font-size:10px; margin-top:2px; }
.cv-add { position:absolute; bottom:4px; right:4px; width:24px; height:24px; border-radius:50%; background:var(--cv-blue); color:white; border:none; cursor:pointer; font-size:16px; line-height:22px; opacity:0; transition:opacity .15s; box-shadow:0 2px 4px rgba(0,0,0,0.15); }
.cv-cell:hover .cv-add { opacity:1; }
@@ -346,9 +347,10 @@
wire:click="openEvent({{ $e['id'] }})"
@dragstart="dragEventId = {{ $e['id'] }}; $el.classList.add('dragging')"
@dragend="$el.classList.remove('dragging')">
<div class="cv-event-title" style="color:{{ $e['color'] }}">{{ $e['client_name'] ?: $e['title'] }}</div>
<div class="cv-event-meta">{{ $e['vehicle'] ?: '—' }}@if($e['plate']) · {{ $e['plate'] }}@endif</div>
<div class="cv-event-time">{{ $e['time'] }}@if($e['master_initial']) · {{ $e['master_initial'] }}@endif</div>
@if($e['title'])<div class="cv-event-title" style="color:{{ $e['color'] }}">{{ $e['title'] }}</div>@endif
@if($e['client_name'])<div class="cv-event-client">👤 {{ $e['client_name'] }}</div>@endif
@if($e['vehicle'] || $e['plate'])<div class="cv-event-vehicle">🚗 {{ $e['vehicle'] }}@if($e['plate']) · {{ $e['plate'] }}@endif</div>@endif
<div class="cv-event-time">🕐 {{ $e['time'] }}@if($e['master_initial']) · {{ $e['master_initial'] }}@endif</div>
</div>
@endforeach
<button class="cv-add" type="button" wire:click="openNewForm({{ $row['id'] }}, '{{ $day['date'] }}')">+</button>
@@ -419,7 +421,7 @@
@endif
</div>
<div class="cv-panel-actions">
<button class="cv-btn" wire:click="deleteEvent({{ $openEvent['id'] }})" onclick="return confirm('Ștergi această programare?')">{{ __('🗑 Șterge') }}</button>
<button class="cv-btn" wire:click="deleteEvent({{ $openEvent['id'] }})" wire:confirm="{{ __('Ștergi această programare?') }}">{{ __('🗑 Șterge') }}</button>
<a class="cv-btn cv-btn-primary" href="{{ route('filament.tenant.resources.appointments.edit', ['record' => $openEvent['id']]) }}">{{ __('↗ Editare') }}</a>
</div>
@endif
@@ -450,8 +452,16 @@
<div class="cv-pfield"><label>{{ __('De la') }}</label><input wire:model="newAppt.time_start" type="time"></div>
<div class="cv-pfield"><label>{{ __('Până la') }}</label><input wire:model="newAppt.time_end" type="time"></div>
</div>
<div class="cv-pfield"><label>{{ __('Client') }}</label>
<select wire:model.live="newAppt.client_id">
<div class="cv-pfield"><label>{{ __('Auto *') }}</label>
<select wire:model.live="newAppt.vehicle_id">
<option value="">— {{ __('alege autovehicul') }} —</option>
@foreach ($this->getAllVehicleOptions() as $id => $label)
<option value="{{ $id }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="cv-pfield"><label>{{ __('Client') }} <span style="font-weight:400;opacity:.6;">({{ __('se completează automat') }})</span></label>
<select wire:model="newAppt.client_id">
<option value="">—</option>
@foreach ($this->getClientOptions() as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@@ -534,4 +544,20 @@
</div>
@endif
</div>
<script>
document.addEventListener('livewire:navigated', initSlowScroll);
document.addEventListener('DOMContentLoaded', initSlowScroll);
function initSlowScroll() {
const wrap = document.querySelector('.cv-matrix-wrap');
if (!wrap || wrap._slowScroll) return;
wrap._slowScroll = true;
wrap.addEventListener('wheel', function(e) {
if (Math.abs(e.deltaX) < Math.abs(e.deltaY) && !e.shiftKey) return;
e.preventDefault();
const delta = e.shiftKey ? e.deltaY : e.deltaX;
wrap.scrollLeft += delta * 0.45;
}, { passive: false });
}
</script>
</x-filament-panels::page>
@@ -0,0 +1,145 @@
<x-filament-panels::page>
{{-- ── Toggle bar ── --}}
<div style="display:flex;justify-content:flex-end;margin-bottom:16px;">
<div style="display:inline-flex;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.06);">
<button type="button"
wire:click="setViewMode('list')"
style="padding:7px 18px;border:none;cursor:pointer;font-size:.875rem;font-weight:500;
display:flex;align-items:center;gap:7px;transition:background .12s;
{{ $this->viewMode === 'list' ? 'background:#2563eb;color:#fff;' : 'background:#fff;color:#4b5563;' }}">
<svg style="width:15px;height:15px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2">
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
{{ __('Listă') }}
</button>
<button type="button"
wire:click="setViewMode('tree')"
style="padding:7px 18px;border:none;cursor:pointer;font-size:.875rem;font-weight:500;
display:flex;align-items:center;gap:7px;transition:background .12s;
border-left:1px solid #e5e7eb;
{{ $this->viewMode === 'tree' ? 'background:#2563eb;color:#fff;' : 'background:#fff;color:#4b5563;' }}">
<svg style="width:15px;height:15px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2">
<path stroke-linecap="round" d="M3 5h4v4H3zM9 7h12M3 12h4v4H3zM9 14h8M3 19h4v4H3zM9 21h5"/>
</svg>
{{ __('Arbore') }}
</button>
</div>
</div>
{{-- ── LIST mode ── --}}
@if ($this->viewMode === 'list')
{{ $this->table }}
@endif
{{-- ── TREE mode ── --}}
@if ($this->viewMode === 'tree')
@php $tree = $this->getLaborTree(); @endphp
<div x-data="{
all: {},
setAll(v) { Object.keys(all).forEach(k => all[k] = v); }
}"
x-init="{{ $tree->map(fn($c, $i) => "all[{$i}] = true")->implode('; ') }}">
{{-- Open/close all --}}
<div style="display:flex;gap:8px;margin-bottom:10px;">
<button type="button" @click="setAll(true)"
style="font-size:.8rem;color:#6b7280;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:4px 12px;cursor:pointer;">
⬇ {{ __('Deschide tot') }}
</button>
<button type="button" @click="setAll(false)"
style="font-size:.8rem;color:#6b7280;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:4px 12px;cursor:pointer;">
⬆ {{ __('Închide tot') }}
</button>
</div>
{{-- Category panels --}}
<div style="display:flex;flex-direction:column;gap:4px;">
@foreach ($tree as $i => $cat)
<div style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
{{-- Header --}}
<button type="button"
@click="all[{{ $i }}] = !all[{{ $i }}]"
style="width:100%;display:flex;align-items:center;justify-content:space-between;
padding:10px 16px;border:none;cursor:pointer;text-align:left;
background:#f9fafb;">
<span style="display:flex;align-items:center;gap:10px;">
<svg :style="all[{{ $i }}] ? 'transform:rotate(90deg)' : ''"
style="width:14px;height:14px;color:#9ca3af;transition:transform .18s;flex-shrink:0;"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" d="M9 5l7 7-7 7"/>
</svg>
<span style="font-weight:600;font-size:.9rem;color:#111827;">{{ $cat->label }}</span>
<span style="font-size:.72rem;color:#9ca3af;background:#e5e7eb;border-radius:20px;padding:1px 8px;font-weight:500;">
{{ $cat->items->count() }}
</span>
</span>
</button>
{{-- Items table --}}
<div x-show="all[{{ $i }}]" x-cloak
style="border-top:1px solid #f3f4f6;background:#fff;">
<table style="width:100%;border-collapse:collapse;font-size:.85rem;">
<thead>
<tr style="color:#9ca3af;font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid #f3f4f6;">
<th style="padding:6px 16px;text-align:left;font-weight:500;">{{ __('Denumire') }}</th>
<th style="padding:6px 8px;text-align:center;font-weight:500;white-space:nowrap;">{{ __('Mod') }}</th>
<th style="padding:6px 8px;text-align:right;font-weight:500;">{{ __('Ore') }}</th>
<th style="padding:6px 8px;text-align:right;font-weight:500;">{{ __('Preț') }}</th>
<th style="padding:6px 8px;text-align:right;font-weight:500;">{{ __('Cost') }}</th>
<th style="padding:6px 16px;text-align:right;font-weight:500;"></th>
</tr>
</thead>
<tbody>
@foreach ($cat->items as $labor)
<tr style="border-top:1px solid #f9fafb;"
onmouseenter="this.style.background='#f9fafb'"
onmouseleave="this.style.background=''">
<td style="padding:8px 16px;max-width:320px;">
<span style="font-weight:500;color:#111827;">{{ $labor->label() }}</span>
@if ($labor->code)
<span style="font-size:.72rem;color:#9ca3af;margin-left:6px;">{{ $labor->code }}</span>
@endif
@if (! $labor->is_active)
<span style="font-size:.7rem;background:#fee2e2;color:#dc2626;border-radius:4px;padding:1px 6px;margin-left:6px;">{{ __('Inactiv') }}</span>
@endif
</td>
<td style="padding:8px;text-align:center;white-space:nowrap;">
<span style="font-size:.72rem;border-radius:4px;padding:2px 8px;
{{ $labor->pricing_mode === 'fixed'
? 'background:#dbeafe;color:#1d4ed8;'
: 'background:#f3f4f6;color:#6b7280;' }}">
{{ $labor->pricing_mode === 'fixed' ? __('Fix') : __('Pe oră') }}
</span>
</td>
<td style="padding:8px;text-align:right;color:#374151;white-space:nowrap;">
{{ $labor->pricing_mode === 'hourly'
? rtrim(rtrim(number_format((float)$labor->hours, 2), '0'), '.')
: '—' }}
</td>
<td style="padding:8px;text-align:right;font-weight:600;color:#111827;white-space:nowrap;">
{{ number_format((float)($labor->pricing_mode === 'fixed' ? $labor->fixed_price : $labor->price), 2, '.', ' ') }} MDL
</td>
<td style="padding:8px;text-align:right;color:#9ca3af;white-space:nowrap;font-size:.8rem;">
{{ $labor->cost_price > 0 ? number_format((float)$labor->cost_price, 2, '.', ' ') . ' MDL' : '—' }}
</td>
<td style="padding:8px 16px;text-align:right;white-space:nowrap;">
<a href="{{ \App\Filament\Tenant\Resources\LaborResource::getUrl('edit', ['record' => $labor->id]) }}"
style="font-size:.8rem;color:#2563eb;text-decoration:none;">
{{ __('Editează') }}
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endforeach
</div>
</div>
@endif
</x-filament-panels::page>
@@ -0,0 +1,210 @@
{{-- Full cookie consent — GDPR/Legea 195/2024 compliant --}}
@php
$bannerEnabled = data_get($tenant->settings ?? [], 'cookie.banner_enabled', true);
$tc = $themeColor ?? '#3B82F6';
@endphp
@if ($bannerEnabled)
<style>
[x-cloak]{display:none!important}
.ck-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10000;display:flex;align-items:flex-end;justify-content:center;padding:0}
@media(min-width:600px){.ck-overlay{align-items:center;padding:20px}}
.ck-modal{background:#fff;width:100%;max-width:560px;border-radius:16px 16px 0 0;padding:24px 20px 20px;max-height:90vh;overflow-y:auto;box-shadow:0 -8px 40px rgba(0,0,0,.18);font-family:system-ui,-apple-system,'Segoe UI',sans-serif}
@media(min-width:600px){.ck-modal{border-radius:16px;max-height:80vh}}
.ck-modal h2{font-size:17px;font-weight:700;color:#111827;margin-bottom:6px}
.ck-modal .ck-intro{font-size:13px;color:#6b7280;margin-bottom:16px;line-height:1.5}
.ck-cat{border:1px solid #e5e7eb;border-radius:10px;padding:14px 14px 12px;margin-bottom:8px}
.ck-cat-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
.ck-cat-head label{font-size:14px;font-weight:600;color:#111827;cursor:pointer;flex:1}
.ck-cat p{font-size:12px;color:#6b7280;margin-top:8px;line-height:1.5}
.ck-toggle{position:relative;width:40px;height:22px;flex-shrink:0}
.ck-toggle input{opacity:0;width:0;height:0;position:absolute}
.ck-slider{position:absolute;inset:0;border-radius:22px;background:#d1d5db;transition:background .2s;cursor:pointer}
.ck-slider:before{content:'';position:absolute;width:16px;height:16px;left:3px;top:3px;border-radius:50%;background:#fff;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.2)}
input:checked+.ck-slider{background:{{ $tc }}}
input:checked+.ck-slider:before{transform:translateX(18px)}
input:disabled+.ck-slider{opacity:.6;cursor:default}
.ck-badge{font-size:11px;background:#f3f4f6;color:#6b7280;border-radius:4px;padding:1px 7px;font-weight:500}
.ck-badge.always{background:#dcfce7;color:#166534}
.ck-btns{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
.ck-btn{flex:1;min-width:120px;padding:10px 12px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:none;white-space:nowrap;text-align:center}
.ck-btn-primary{background:{{ $tc }};color:#fff}
.ck-btn-secondary{background:#f3f4f6;color:#374151;border:1px solid #e5e7eb}
.ck-btn-ghost{background:transparent;color:#6b7280;border:1px solid #d1d5db}
.ck-link{color:{{ $tc }};text-decoration:none;font-size:12px}
.ck-link:hover{opacity:.8}
.ck-footer{display:flex;justify-content:space-between;align-items:center;margin-top:14px;padding-top:12px;border-top:1px solid #f3f4f6;flex-wrap:wrap;gap:8px}
/* Floating re-open button */
.ck-fab{position:fixed;bottom:16px;left:16px;z-index:9000;background:#fff;border:1px solid #e5e7eb;border-radius:50%;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.12);font-size:20px;transition:box-shadow .15s}
.ck-fab:hover{box-shadow:0 4px 16px rgba(0,0,0,.18)}
@media(prefers-color-scheme:dark){
.ck-modal{background:#1e293b;color:#f1f5f9}
.ck-modal h2{color:#f1f5f9}
.ck-modal .ck-intro{color:#94a3b8}
.ck-cat{border-color:#334155}
.ck-cat-head label{color:#f1f5f9}
.ck-cat p{color:#94a3b8}
.ck-slider{background:#475569}
.ck-badge{background:#334155;color:#94a3b8}
.ck-badge.always{background:#14532d;color:#86efac}
.ck-btn-secondary{background:#334155;color:#f1f5f9;border-color:#475569}
.ck-btn-ghost{color:#94a3b8;border-color:#475569}
.ck-footer{border-color:#334155}
.ck-fab{background:#1e293b;border-color:#334155;color:#f1f5f9}
}
</style>
<div
x-data="cookieConsent()"
x-init="init()"
>
{{-- Backdrop + Modal --}}
<div class="ck-overlay" x-show="open" x-cloak @click.self="open=false">
<div class="ck-modal" @click.stop>
<div x-show="!showDetails">
{{-- Simple banner view --}}
<h2>🍪 {{ __('portal.cookie.banner_title') }}</h2>
<p class="ck-intro">
{{ __('portal.cookie.banner_body') }}
<a href="{{ route('cookie.policy') }}" class="ck-link" target="_blank" rel="noopener">{{ __('portal.cookie.read_policy') }} →</a>
</p>
<div class="ck-btns">
<button class="ck-btn ck-btn-primary" @click="acceptAll()">{{ __('portal.cookie.accept') }}</button>
<button class="ck-btn ck-btn-secondary" @click="showDetails=true">{{ __('portal.cookie.customize') }}</button>
<button class="ck-btn ck-btn-ghost" @click="rejectAll()">{{ __('portal.cookie.reject') }}</button>
</div>
</div>
<div x-show="showDetails" x-cloak>
{{-- Detailed categories view --}}
<h2>{{ __('portal.cookie.settings_title') }}</h2>
<p class="ck-intro">{{ __('portal.cookie.settings_body') }}</p>
{{-- Necessary --}}
<div class="ck-cat">
<div class="ck-cat-head">
<label>{{ __('portal.cookie.cat_necessary') }}</label>
<span class="ck-badge always">{{ __('portal.cookie.always_on') }}</span>
<label class="ck-toggle">
<input type="checkbox" checked disabled>
<span class="ck-slider"></span>
</label>
</div>
<p>{{ __('portal.cookie.cat_necessary_desc') }}</p>
</div>
{{-- Functional --}}
<div class="ck-cat">
<div class="ck-cat-head">
<label for="ck_func">{{ __('portal.cookie.cat_functional') }}</label>
<label class="ck-toggle">
<input id="ck_func" type="checkbox" x-model="consent.functional">
<span class="ck-slider"></span>
</label>
</div>
<p>{{ __('portal.cookie.cat_functional_desc') }}</p>
</div>
{{-- Analytics --}}
<div class="ck-cat">
<div class="ck-cat-head">
<label for="ck_anal">{{ __('portal.cookie.cat_analytics') }}</label>
<label class="ck-toggle">
<input id="ck_anal" type="checkbox" x-model="consent.analytics">
<span class="ck-slider"></span>
</label>
</div>
<p>{{ __('portal.cookie.cat_analytics_desc') }}</p>
</div>
{{-- Marketing --}}
<div class="ck-cat">
<div class="ck-cat-head">
<label for="ck_mktg">{{ __('portal.cookie.cat_marketing') }}</label>
<label class="ck-toggle">
<input id="ck_mktg" type="checkbox" x-model="consent.marketing">
<span class="ck-slider"></span>
</label>
</div>
<p>{{ __('portal.cookie.cat_marketing_desc') }}</p>
</div>
<div class="ck-btns">
<button class="ck-btn ck-btn-primary" @click="saveSettings()">{{ __('portal.cookie.save_settings') }}</button>
<button class="ck-btn ck-btn-secondary" @click="acceptAll()">{{ __('portal.cookie.accept') }}</button>
</div>
<div class="ck-footer">
<a href="{{ route('cookie.policy') }}" class="ck-link" target="_blank" rel="noopener">{{ __('portal.cookie.read_policy') }}</a>
<button class="ck-btn-ghost" style="border:none;background:transparent;font-size:12px;color:#6b7280;cursor:pointer;" @click="showDetails=false">← {{ __('portal.cookie.back') }}</button>
</div>
</div>
</div>
</div>
{{-- Floating re-open button (shown after consent given) --}}
<button class="ck-fab" x-show="!open && resolved" x-cloak @click="open=true;showDetails=true" title="{{ __('portal.cookie.manage_title') }}">🍪</button>
</div>
<script>
function cookieConsent() {
return {
open: false,
resolved: false,
showDetails: false,
consent: { necessary: true, functional: true, analytics: false, marketing: false },
init() {
const saved = localStorage.getItem('cookie_consent_v2');
if (saved) {
try {
const data = JSON.parse(saved);
this.consent = { necessary: true, ...data };
this.resolved = true;
this._apply();
} catch(e) {
this.open = true;
}
} else {
// Small delay so page content loads first
setTimeout(() => { this.open = true; }, 600);
}
},
acceptAll() {
this.consent = { necessary: true, functional: true, analytics: true, marketing: true };
this._save();
},
rejectAll() {
this.consent = { necessary: true, functional: false, analytics: false, marketing: false };
this._save();
},
saveSettings() {
this.consent.necessary = true;
this._save();
this.showDetails = false;
},
_save() {
localStorage.setItem('cookie_consent_v2', JSON.stringify(this.consent));
this.resolved = true;
this.open = false;
this._apply();
},
_apply() {
window.cookieConsent = { ...this.consent };
window.dispatchEvent(new CustomEvent('cookie-consent-updated', { detail: { ...this.consent } }));
// Load deferred analytics/marketing scripts based on consent
if (this.consent.analytics) {
document.dispatchEvent(new CustomEvent('cookie-analytics-granted'));
}
if (this.consent.marketing) {
document.dispatchEvent(new CustomEvent('cookie-marketing-granted'));
}
}
};
}
</script>
@endif
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="{{ $locale }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ __('portal.cookie.policy_title') }} — {{ $tenant?->display_name ?? $tenant?->name ?? config('app.name') }}</title>
@if ($faviconUrl ?? null)
<link rel="icon" href="{{ $faviconUrl }}">
@endif
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; color: #1f2937; background: #f3f4f6; line-height: 1.6; }
header { background: {{ $themeColor }}; color: #fff; padding: 28px 16px; text-align: center; }
header img { max-height: 48px; margin-bottom: 10px; display: block; margin-inline: auto; }
header h1 { font-size: 20px; font-weight: 700; }
.wrap { max-width: 760px; margin: 0 auto; padding: 24px 16px 80px; }
.card { background: #fff; border-radius: 12px; padding: 28px 24px; margin-top: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }
.card-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; color: {{ $themeColor }}; }
.policy-text { font-size: 13.5px; white-space: pre-wrap; color: #374151; line-height: 1.75; }
.back-link { display: inline-flex; align-items: center; gap: 6px; color: {{ $themeColor }}; text-decoration: none; font-size: 14px; font-weight: 500; margin-bottom: 8px; }
.back-link:hover { opacity: .8; }
.manage-btn { display: inline-flex; align-items: center; gap: 6px; background: {{ $themeColor }}; color: #fff; border: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; text-decoration: none; }
.manage-btn:hover { opacity: .9; }
footer { text-align: center; padding: 24px 16px; color: #9ca3af; font-size: 12px; }
[x-cloak] { display: none !important; }
@media (prefers-color-scheme: dark) {
body { background: #0f172a; color: #e5e7eb; }
.card { background: #1e293b; box-shadow: none; }
.policy-text { color: #cbd5e1; }
footer { color: #64748b; }
}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<header>
@if ($logoUrl ?? null)
<img src="{{ $logoUrl }}" alt="">
@endif
<h1>{{ $tenant?->display_name ?? $tenant?->name }}</h1>
</header>
<div class="wrap">
<a href="javascript:history.back()" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24">
<path stroke-linecap="round" d="M15 19l-7-7 7-7"/>
</svg>
{{ __('portal.cookie.back') }}
</a>
<div class="card" style="margin-bottom:12px;">
<p class="card-title">{{ __('portal.cookie.policy_title') }}</p>
<p class="policy-text">{{ $policyText }}</p>
</div>
<div class="card" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
<div>
<p style="font-size:14px;font-weight:600;color:#111827;margin-bottom:2px;">{{ __('portal.cookie.manage_title') }}</p>
<p style="font-size:13px;color:#6b7280;">{{ __('portal.cookie.settings_body') }}</p>
</div>
<button class="manage-btn" onclick="openCookieSettings()">
🍪 {{ __('portal.cookie.customize') }}
</button>
</div>
<footer>{{ __('portal.common.powered_by') }}</footer>
</div>
@php $themeColor = $themeColor ?? '#3B82F6'; @endphp
@include('partials.cookie-banner')
<script>
function openCookieSettings() {
// Reset consent so the banner re-appears with settings panel open
localStorage.removeItem('cookie_consent_v2');
location.reload();
}
</script>
</body>
</html>
+6
View File
@@ -48,7 +48,9 @@
footer { background: #1f2937; color: #9ca3af; padding: 24px; text-align: center; font-size: 13px; }
footer a { color: #d1d5db; text-decoration: none; }
[x-cloak] { display: none !important; }
</style>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
@@ -116,5 +118,9 @@
© {{ date('Y') }} {{ $name }} · powered by AutoCRM
</footer>
@php
$tenant = app(\App\Tenancy\TenantManager::class)->current();
@endphp
@include('partials.cookie-banner')
</body>
</html>
+4
View File
@@ -67,6 +67,7 @@
.totals .amt { font-size: 22px; font-weight: 700; color: {{ $themeColor }}; }
footer { text-align: center; padding: 24px 16px; color: #9ca3af; font-size: 12px; }
[x-cloak] { display: none !important; }
@media (prefers-color-scheme: dark) {
body { background: #0f172a; color: #e5e7eb; }
.card { background: #1e293b; box-shadow: none; }
@@ -80,6 +81,7 @@
footer { color: #64748b; }
}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<header style="position:relative;">
@@ -228,5 +230,7 @@
{{ __('portal.common.powered_by') }}
</footer>
</div>
@include('partials.cookie-banner')
</body>
</html>
+4
View File
@@ -185,6 +185,10 @@ Route::controller(\App\Http\Controllers\ShopAuthController::class)->prefix('shop
Route::post('/password/reset', 'resetPassword')->name('shop.password.update')->middleware('throttle:5,1,shop-pw-reset');
});
// ─── Cookie policy (public, tenant-scoped via subdomain) ────────────
Route::get('/cookie-policy', [\App\Http\Controllers\CookiePolicyController::class, 'show'])
->name('cookie.policy');
// ─── Public WO tracking (no auth, tenant-scoped via subdomain) ──────
Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show'])
->where('token', '[A-Za-z0-9]{16,32}')