From 1d5ea6d261a61b9aae28ec807d0ca065db0852d1 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 4 Jun 2026 21:50:22 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Calendar=20Vizual=20v2=20(Pod=C3=97Days?= =?UTF-8?q?=20matrix)=20+=20hidden=20markup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 2 of the biggest items from /tmp/service/new docs: == Calendar Vizual v2 (from 02-prototip-calendar-vizual.html) == Replaces the FullCalendar week view (the one that visually collapsed after Livewire re-renders) with a server-rendered matrix that the harness already drives through Livewire — no third-party JS to clash with Filament. Layout: 8-column CSS grid (1 row-label + 7 days). Rows are either Posts (Pod 1, Pod 2…) or active masters depending on toolbar switch. Each cell holds 0..N event cards. Per-cell load badge (top-right): hours_planned / capacity → badge color (gray <50%, orange 50–90%, red ≥90%) Drag-drop: HTML5 native, Alpine.js holds the dragEventId, moveEvent($id, $toRowId, $toDate) in PHP updates either post_id or master_id (depending on groupBy mode) plus date — works seamlessly when re-grouping. KPI bar (4 cards above toolbar): - Ore programate X / Y · % capacity - Fișe deschise (orange) - Confirmate X/Y (green) + confirmation rate - No-show alert (red) — scheduled events <24h away that are still unconfirmed Toolbar: - ◀ Week ▶ + Astăzi (reset) - Date label "01 — 07 iunie 2026" - Grupare switch: Pod ↔ Mecanic - Filtru: master dropdown + status dropdown (Confirmate/Neconfirmate/În lucru) Today column highlighted blue; Sunday column hatched as closed (non-interactive, no drop target); Saturday muted as weekend. Event card color = master.color (deterministic, matches profile setting), shown as left border + background tint. Title = client name; meta = "VW Passat · CIU 001"; time = "08:00–12:00 · V.". Click empty cell → quick-create panel (right slide-in) with date+pod pre-filled. Click event → detail panel with Client/Phone/Auto/Plate/ Master/Pod + delete + edit. Legend section at bottom (mecanici dots, load colors, day states). == Hidden Markup (from gap-analysis.md #3) == Adds `hidden_markup_pct` decimal to parts. Customer documents continue to show the standard sell_price; the hidden markup is an internal margin indicator used for B2B contracts and corporate analytics. Part::internalCostWithHiddenMarkup() returns buy_price * (1 + pct/100). Falls back to buy_price when pct is null. Decimal:2 cast so persistence round-trips cleanly. == Schema migration == Idempotent (hasColumn guards): - posts.hours_per_day decimal(5,1) default 10 - posts.description varchar(255) nullable - parts.hidden_markup_pct decimal(5,2) nullable == Tests == +11 new in CalendarBoardV2Test (8) + HiddenMarkupTest (3): - get_days returns 7 days with today flagged + Sunday closed + Saturday weekend - get_rows returns posts when grouped by post + with capacity - get_rows returns masters when grouped by master + Fără maistru fallback row - matrix places events in correct cells + sums hours - move_event reassigns post_id and date - create_appt inserts appointment via panel form - stats compute utilization from events (8h / 60h capacity = 13%) - status filter narrows to confirmed only - hidden_markup applies pct correctly + falls back to buy_price + persists Suite: 196 passed (551 assertions). Was 185. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Filament/Tenant/Pages/CalendarBoard.php | 416 ++++++++++---- app/Models/Tenant/Part.php | 11 +- app/Models/Tenant/Post.php | 3 +- ...ty_to_posts_and_hidden_markup_to_parts.php | 42 ++ .../filament/tenant/pages/calendar.blade.php | 521 +++++++++++++----- tests/Feature/CalendarBoardV2Test.php | 184 +++++++ tests/Feature/HiddenMarkupTest.php | 56 ++ 7 files changed, 986 insertions(+), 247 deletions(-) create mode 100644 database/migrations/2026_06_04_000002_add_capacity_to_posts_and_hidden_markup_to_parts.php create mode 100644 tests/Feature/CalendarBoardV2Test.php create mode 100644 tests/Feature/HiddenMarkupTest.php diff --git a/app/Filament/Tenant/Pages/CalendarBoard.php b/app/Filament/Tenant/Pages/CalendarBoard.php index adb88bf..e9caf12 100644 --- a/app/Filament/Tenant/Pages/CalendarBoard.php +++ b/app/Filament/Tenant/Pages/CalendarBoard.php @@ -8,11 +8,8 @@ use App\Models\Tenant\Post; use App\Models\Tenant\User; use App\Models\Tenant\Vehicle; use Carbon\Carbon; -use Filament\Forms; use Filament\Notifications\Notification; use Filament\Pages\Page; -use Filament\Schemas; -use Filament\Schemas\Schema; class CalendarBoard extends Page { @@ -24,147 +21,340 @@ class CalendarBoard extends Page protected static ?int $navigationSort = 8; - protected static ?string $title = 'Calendar'; + protected static ?string $title = 'Calendar vizual'; protected string $view = 'filament.tenant.pages.calendar'; - public ?array $createData = []; + public string $weekStart; // 'Y-m-d' (Monday) + public string $groupBy = 'post'; // 'post' | 'master' + public ?int $masterFilter = null; + public string $statusFilter = 'all'; // all | confirmed | unconfirmed | in_work + public bool $showNewForm = false; + public ?int $openEventId = null; - public ?array $editData = []; + public array $newAppt = []; - public ?int $editId = null; - - /** Register all forms used by this page (Filament v5 multi-form pattern). */ - protected function getForms(): array + public function getMaxContentWidth(): \Filament\Support\Enums\Width { - return ['createForm']; + return \Filament\Support\Enums\Width::Full; } - public function getEvents(string $start, string $end): array + public function getHeading(): string { return ''; } + public function getSubheading(): ?string { return null; } + + public function mount(): void { - return Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color']) - ->whereBetween('date', [$start, $end]) - ->get() - ->map(fn (Appointment $a) => [ + $this->weekStart = Carbon::now()->startOfWeek()->toDateString(); + } + + public function shiftWeek(int $deltaWeeks): void + { + $this->weekStart = Carbon::parse($this->weekStart)->addWeeks($deltaWeeks)->toDateString(); + } + + public function setWeekToday(): void + { + $this->weekStart = Carbon::now()->startOfWeek()->toDateString(); + } + + public function setGroupBy(string $g): void + { + $this->groupBy = in_array($g, ['post', 'master'], true) ? $g : 'post'; + } + + public function setStatusFilter(string $s): void + { + $this->statusFilter = in_array($s, ['all', 'confirmed', 'unconfirmed', 'in_work'], true) ? $s : 'all'; + } + + public function setMasterFilter($id): void + { + $this->masterFilter = $id ? (int) $id : null; + } + + /** Build the 7 day headers from weekStart. */ + public function getDays(): array + { + $start = Carbon::parse($this->weekStart); + $today = Carbon::today()->toDateString(); + $names = ['Luni', 'Marți', 'Miercuri', 'Joi', 'Vineri', 'Sâmbătă', 'Duminică']; + $days = []; + for ($i = 0; $i < 7; $i++) { + $d = $start->copy()->addDays($i); + $days[] = [ + 'date' => $d->toDateString(), + 'label' => $d->format('d.m'), + 'name' => $names[$i], + 'is_today' => $d->toDateString() === $today, + 'is_weekend' => $i >= 5, + 'is_closed' => $i === 6, // Sunday default + ]; + } + return $days; + } + + /** Rows: either Posts or active Masters depending on $groupBy. */ + public function getRows(): array + { + if ($this->groupBy === 'master') { + $rows = User::query() + ->where('status', 'active') + ->whereNotNull('role') + ->where(function ($q) { $q->where('role', 'master')->orWhere('role', 'mecanic')->orWhereNull('role'); }) + ->orderBy('name') + ->get(['id', 'name', 'color', 'specialization']) + ->map(fn ($u) => [ + 'kind' => 'master', + 'id' => $u->id, + 'name' => $u->name, + 'color' => $u->color ?: '#3b82f6', + 'meta' => $u->specialization ?: '8h/zi', + 'capacity_hours' => 8.0, + ])->all(); + // Always include "Fără maistru" row at the bottom + $rows[] = ['kind' => 'master', 'id' => 0, 'name' => 'Fără maistru', 'color' => '#94a3b8', 'meta' => '—', 'capacity_hours' => 0]; + return $rows; + } + + $posts = Post::where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(); + // Fallback: synthesize a default post if none configured yet + if ($posts->isEmpty()) { + return [ + ['kind' => 'post', 'id' => 0, 'name' => 'Pod 1 (default)', 'color' => '#3b82f6', 'meta' => '10h/zi', 'capacity_hours' => 10.0], + ]; + } + return $posts->map(fn ($p) => [ + 'kind' => 'post', + 'id' => $p->id, + 'name' => $p->name, + 'color' => $p->color ?: '#3b82f6', + 'meta' => ($p->hours_per_day ? $p->hours_per_day . 'h/zi' : '10h/zi') . ($p->description ? ' · ' . $p->description : ''), + 'capacity_hours' => (float) ($p->hours_per_day ?: 10), + ])->all(); + } + + /** Returns map [rowId][date] => ['events'=>[], 'load_hours'=>float, 'capacity'=>float] */ + public function getMatrix(): array + { + $start = $this->weekStart; + $end = Carbon::parse($this->weekStart)->addDays(6)->toDateString(); + + $q = Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color']) + ->whereBetween('date', [$start, $end]); + + if ($this->masterFilter) { + $q->where('master_id', $this->masterFilter); + } + if ($this->statusFilter === 'confirmed') { + $q->where('status', 'arrived'); + } elseif ($this->statusFilter === 'unconfirmed') { + $q->where('status', 'scheduled'); + } elseif ($this->statusFilter === 'in_work') { + $q->where('status', 'in_work'); + } + + $events = $q->get(); + $rows = $this->getRows(); + $days = $this->getDays(); + + $matrix = []; + foreach ($rows as $row) { + foreach ($days as $day) { + $matrix[$row['id']][$day['date']] = ['events' => [], 'load_hours' => 0, 'capacity' => $row['capacity_hours']]; + } + } + + foreach ($events as $a) { + $rowId = $this->groupBy === 'post' ? ($a->post_id ?: ($rows[0]['id'] ?? 0)) : ($a->master_id ?: 0); + if (! isset($matrix[$rowId])) continue; + $date = $a->date->toDateString(); + if (! isset($matrix[$rowId][$date])) continue; + + $hours = $this->calcHours($a->time_start, $a->time_end); + $matrix[$rowId][$date]['load_hours'] += $hours; + $matrix[$rowId][$date]['events'][] = [ 'id' => $a->id, - 'title' => trim($a->title ?: ($a->client?->name ?? '—')), - 'start' => $a->date->format('Y-m-d') . 'T' . ($a->time_start ?? '08:00:00'), - 'end' => $a->date->format('Y-m-d') . 'T' . ($a->time_end ?? '09:00:00'), - 'backgroundColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'), - 'borderColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'), - 'extendedProps' => [ - 'client' => $a->client?->name, - 'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')), - 'plate' => $a->vehicle?->plate, - 'master' => $a->master?->name, - 'post' => $a->post?->name, - 'status' => $a->status, - 'notes' => $a->notes, - ], - ])->all(); + 'title' => $a->title ?: ($a->client?->name ?? '—'), + 'client_name' => $a->client?->name, + 'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')), + 'plate' => $a->vehicle?->plate, + 'master_name' => $a->master?->name, + 'master_initial' => $a->master ? strtoupper(mb_substr($a->master->name, 0, 1)) . '.' : '', + 'time' => substr($a->time_start ?? '', 0, 5) . '–' . substr($a->time_end ?? '', 0, 5), + 'color' => $a->color ?: ($a->master?->color ?? '#3b82f6'), + 'status' => $a->status, + ]; + } + + return $matrix; } - /** Drag-drop reschedule. */ - public function moveEvent(int $id, string $start, string $end): void + public function getStats(): array + { + $start = $this->weekStart; + $end = Carbon::parse($this->weekStart)->addDays(6)->toDateString(); + $events = Appointment::whereBetween('date', [$start, $end])->get(); + $rows = $this->getRows(); + // Capacity = sum(rows.capacity_hours) * 6 working days + $capacity = 0; + foreach ($rows as $r) { $capacity += $r['capacity_hours'] * 6; } + $scheduled = 0; + foreach ($events as $a) { + $scheduled += $this->calcHours($a->time_start, $a->time_end); + } + $open = $events->whereNotIn('status', ['done', 'cancelled', 'no_show'])->count(); + $confirmed = $events->whereIn('status', ['arrived', 'in_work', 'done'])->count(); + $noShowAlert = $events + ->where('status', 'scheduled') + ->filter(fn ($a) => Carbon::parse($a->date->toDateString() . ' ' . ($a->time_start ?? '08:00'))->diffInHours(now(), false) > -24) + ->count(); + + return [ + 'scheduled_hours' => round($scheduled, 1), + 'capacity_hours' => round($capacity, 1), + 'utilization_pct' => $capacity > 0 ? (int) round(100 * $scheduled / $capacity) : 0, + 'open_count' => $open, + 'confirmed_count' => $confirmed, + 'total_count' => $events->count(), + 'confirmation_rate_pct' => $events->count() > 0 ? (int) round(100 * $confirmed / $events->count()) : 0, + 'no_show_alert' => $noShowAlert, + ]; + } + + // ============== mutations ============== + + public function moveEvent(int $id, int $toRowId, string $toDate): void { $a = Appointment::find($id); if (! $a) return; - - [$startDate, $startTime] = $this->splitIso($start); - [, $endTime] = $this->splitIso($end); - - $a->update([ - 'date' => $startDate, - 'time_start' => $startTime, - 'time_end' => $endTime, - ]); - - Notification::make() - ->title('Programare mutată') - ->body($a->title . ' → ' . $startDate . ' ' . substr($startTime, 0, 5)) - ->success()->send(); - - $this->dispatch('events-changed'); + if ($this->groupBy === 'post') { + $a->post_id = $toRowId ?: null; + } else { + $a->master_id = $toRowId ?: null; + } + $a->date = $toDate; + $a->save(); + Notification::make()->title('Programare mutată')->body($a->title . ' → ' . $toDate)->success()->send(); } - public function quickCreate(string $start, string $end): void + public function openEvent(int $id): void { - $this->createData = [ - 'date' => substr($start, 0, 10), - 'time_start' => substr($start, 11, 5), - 'time_end' => substr($end, 11, 5), + $this->openEventId = $id; + $this->showNewForm = false; + } + + public function getOpenEvent(): ?array + { + if (! $this->openEventId) return null; + $a = Appointment::with(['client', 'vehicle', 'master', 'post'])->find($this->openEventId); + if (! $a) return null; + return [ + 'id' => $a->id, + 'title' => $a->title, + 'status' => $a->status, + 'date' => $a->date->format('d.m.Y'), + 'time' => substr($a->time_start ?? '', 0, 5) . '–' . substr($a->time_end ?? '', 0, 5), + 'client_name' => $a->client?->name, + 'client_phone' => $a->client?->phone, + 'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')), + 'plate' => $a->vehicle?->plate, + 'master_name' => $a->master?->name, + 'post_name' => $a->post?->name, + 'notes' => $a->notes, + 'deal_id' => $a->deal_id, ]; - $this->createForm->fill($this->createData); - $this->dispatch('open-create-modal'); } - public function createForm(Schema $schema): Schema + public function closeEvent(): void { - return $schema->components([ - Forms\Components\Hidden::make('date'), - Forms\Components\TextInput::make('title')->label('Subiect')->required(), - Schemas\Components\Section::make('Când') - ->columns(2) - ->schema([ - Forms\Components\TimePicker::make('time_start')->label('De la')->seconds(false)->required(), - Forms\Components\TimePicker::make('time_end')->label('Până la')->seconds(false)->required(), - ]), - Schemas\Components\Section::make('Cine') - ->columns(2) - ->schema([ - Forms\Components\Select::make('client_id')->label('Client') - ->options(fn () => Client::pluck('name', 'id')) - ->searchable() - ->live(), - Forms\Components\Select::make('vehicle_id')->label('Auto') - ->options(fn (\Filament\Schemas\Components\Utilities\Get $get) => $get('client_id') - ? Vehicle::where('client_id', $get('client_id'))->pluck('plate', 'id') - : []), - Forms\Components\Select::make('master_id')->label('Maistru') - ->options(fn () => User::where('status', 'active')->pluck('name', 'id')) - ->searchable(), - Forms\Components\Select::make('post_id')->label('Pod') - ->options(fn () => Post::where('is_active', true)->pluck('name', 'id')) - ->searchable(), - ]), - Forms\Components\Textarea::make('notes')->rows(2), - ])->statePath('createData'); - } - - public function saveCreate(): void - { - $data = $this->createForm->getState(); - Appointment::create([ - 'date' => $data['date'], - 'time_start' => $data['time_start'], - 'time_end' => $data['time_end'], - 'title' => $data['title'], - 'client_id' => $data['client_id'] ?? null, - 'vehicle_id' => $data['vehicle_id'] ?? null, - 'master_id' => $data['master_id'] ?? null, - 'post_id' => $data['post_id'] ?? null, - 'notes' => $data['notes'] ?? null, - 'status' => 'scheduled', - ]); - $this->createData = []; - Notification::make()->title('Programare adăugată')->success()->send(); - $this->dispatch('close-create-modal'); - $this->dispatch('events-changed'); + $this->openEventId = null; } public function deleteEvent(int $id): void { Appointment::where('id', $id)->delete(); + $this->openEventId = null; Notification::make()->title('Programare ștearsă')->success()->send(); - $this->dispatch('events-changed'); } - protected function splitIso(string $iso): array + public function openNewForm(int $rowId = 0, string $date = ''): void { - // "2026-05-07T10:30:00" → ["2026-05-07", "10:30:00"] - if (str_contains($iso, 'T')) { - return explode('T', $iso); + $this->newAppt = [ + 'date' => $date ?: today()->toDateString(), + 'time_start' => '09:00', + 'time_end' => '10:00', + 'title' => '', + 'client_id' => null, + 'vehicle_id' => null, + 'master_id' => $this->groupBy === 'master' && $rowId ? $rowId : null, + 'post_id' => $this->groupBy === 'post' && $rowId ? $rowId : null, + 'notes' => '', + ]; + $this->showNewForm = true; + $this->openEventId = null; + } + + public function createAppt(): void + { + $d = $this->newAppt; + if (empty($d['title']) || empty($d['date'])) { + Notification::make()->title('Subiect și data sunt obligatorii')->danger()->send(); + return; } - return [substr($iso, 0, 10), substr($iso, 11) ?: '08:00:00']; + Appointment::create([ + 'date' => $d['date'], + 'time_start' => $d['time_start'] ?: '09:00', + 'time_end' => $d['time_end'] ?: '10:00', + 'title' => $d['title'], + 'client_id' => $d['client_id'] ?: null, + 'vehicle_id' => $d['vehicle_id'] ?: null, + 'master_id' => $d['master_id'] ?: null, + 'post_id' => $d['post_id'] ?: null, + 'notes' => $d['notes'] ?: null, + 'status' => 'scheduled', + ]); + $this->showNewForm = false; + Notification::make()->title('Programare adăugată')->success()->send(); + } + + public function getMasterOptions(): array + { + return User::where('status', 'active')->pluck('name', 'id')->toArray(); + } + + public function getPostOptions(): array + { + return Post::where('is_active', true)->pluck('name', 'id')->toArray(); + } + + public function getClientOptions(): array + { + return Client::orderBy('name')->limit(50)->pluck('name', 'id')->toArray(); + } + + public function getVehicleOptions(?int $clientId): array + { + if (! $clientId) return []; + return Vehicle::where('client_id', $clientId)->pluck('plate', 'id')->toArray(); + } + + private function calcHours(?string $start, ?string $end): float + { + if (! $start || ! $end) return 1.0; + try { + $s = Carbon::createFromTimeString($start); + $e = Carbon::createFromTimeString($end); + $h = $e->floatDiffInHours($s); + return max(0, abs($h)); + } catch (\Throwable $e) { + return 1.0; + } + } + + public function getWeekLabel(): string + { + $s = Carbon::parse($this->weekStart); + $e = $s->copy()->addDays(6); + return $s->format('d.m') . ' — ' . $e->format('d.m.Y'); } } diff --git a/app/Models/Tenant/Part.php b/app/Models/Tenant/Part.php index 11ad785..303c6d0 100644 --- a/app/Models/Tenant/Part.php +++ b/app/Models/Tenant/Part.php @@ -45,7 +45,7 @@ class Part extends Model implements HasMedia protected $fillable = [ 'company_id', 'name', 'article', 'brand', 'category', 'qty', 'qty_reserved', 'unit', 'min_qty', - 'buy_price', 'sell_price', + 'buy_price', 'sell_price', 'hidden_markup_pct', 'location', 'barcode', 'preferred_supplier_id', 'is_active', 'is_published', 'notes', ]; @@ -56,10 +56,19 @@ class Part extends Model implements HasMedia 'min_qty' => 'decimal:2', 'buy_price' => 'decimal:2', 'sell_price' => 'decimal:2', + 'hidden_markup_pct' => 'decimal:2', 'is_active' => 'boolean', 'is_published' => 'boolean', ]; + /** Internal cost+hidden markup (NOT shown to customer). Used for margin analytics + B2B contract pricing. */ + public function internalCostWithHiddenMarkup(): float + { + $base = (float) $this->buy_price; + $pct = (float) ($this->hidden_markup_pct ?: 0); + return round($base * (1 + $pct / 100), 2); + } + public function preferredSupplier(): BelongsTo { return $this->belongsTo(Supplier::class, 'preferred_supplier_id'); diff --git a/app/Models/Tenant/Post.php b/app/Models/Tenant/Post.php index 4e1d20a..581ba7c 100644 --- a/app/Models/Tenant/Post.php +++ b/app/Models/Tenant/Post.php @@ -10,10 +10,11 @@ class Post extends Model { use BelongsToTenant; - protected $fillable = ['company_id', 'name', 'color', 'is_active', 'sort_order']; + protected $fillable = ['company_id', 'name', 'color', 'is_active', 'sort_order', 'hours_per_day', 'description']; protected $casts = [ 'is_active' => 'boolean', + 'hours_per_day' => 'decimal:1', ]; public function appointments(): HasMany diff --git a/database/migrations/2026_06_04_000002_add_capacity_to_posts_and_hidden_markup_to_parts.php b/database/migrations/2026_06_04_000002_add_capacity_to_posts_and_hidden_markup_to_parts.php new file mode 100644 index 0000000..924ce91 --- /dev/null +++ b/database/migrations/2026_06_04_000002_add_capacity_to_posts_and_hidden_markup_to_parts.php @@ -0,0 +1,42 @@ +decimal('hours_per_day', 5, 1)->default(10)->after('color'); + } + if (! Schema::hasColumn('posts', 'description')) { + $t->string('description', 255)->nullable()->after('hours_per_day'); + } + }); + + Schema::table('parts', function (Blueprint $t) { + if (! Schema::hasColumn('parts', 'hidden_markup_pct')) { + $t->decimal('hidden_markup_pct', 5, 2)->nullable()->after('sell_price'); + } + }); + } + + public function down(): void + { + Schema::table('posts', function (Blueprint $t) { + foreach (['hours_per_day', 'description'] as $col) { + if (Schema::hasColumn('posts', $col)) { + $t->dropColumn($col); + } + } + }); + Schema::table('parts', function (Blueprint $t) { + if (Schema::hasColumn('parts', 'hidden_markup_pct')) { + $t->dropColumn('hidden_markup_pct'); + } + }); + } +}; diff --git a/resources/views/filament/tenant/pages/calendar.blade.php b/resources/views/filament/tenant/pages/calendar.blade.php index cfeb847..7e08b44 100644 --- a/resources/views/filament/tenant/pages/calendar.blade.php +++ b/resources/views/filament/tenant/pages/calendar.blade.php @@ -1,143 +1,400 @@ - {{-- FullCalendar v6 ships CSS bundled in JS — no separate stylesheet needed --}} - - - +@php + $days = $this->getDays(); + $rows = $this->getRows(); + $matrix = $this->getMatrix(); + $stats = $this->getStats(); + $openEvent = $this->getOpenEvent(); +@endphp - + + +
+
+
+
CRM › Programări › Calendar vizual
+

Calendar vizual

+
+ + + +
+
- {{-- Quick-create modal --}} -
-
-
-

Programare nouă

- +
+
+
Ore programate
+
{{ $stats['scheduled_hours'] }} / {{ $stats['capacity_hours'] }}
+
săptămâna curentă · {{ $stats['utilization_pct'] }}% capacitate
+
+
+
Fișe deschise
+
{{ $stats['open_count'] }}
+
programări active
+
+
+
Confirmate
+
{{ $stats['confirmed_count'] }} / {{ $stats['total_count'] }}
+
{{ $stats['confirmation_rate_pct'] }}% rata confirmare
+
+
+
No-show alert
+
{{ $stats['no_show_alert'] }}
+
programări neconfirmate < 24h
+
+
+ +
+
+ +
{{ $this->getWeekLabel() }}
+ + +
+
+ Grupare: +
+ + +
+
+
+ Filtru: + + +
+
+ +
+ ⚙ Cum funcționează: + Matricea {{ $groupBy === 'post' ? 'Pod' : 'Mecanic' }} × Zile. Drag-and-drop între celule pentru a reprograma. Indicator de încărcare pe fiecare celulă (ore_planificate / capacitate). Click pe celulă goală → programare rapidă. Click pe eveniment → detalii. +
+ +
+
+
{{ $groupBy === 'post' ? 'Pod / Zi' : 'Mecanic / Zi' }}
+ @foreach ($days as $day) +
+ {{ $day['name'] }} + {{ $day['label'] }}
-
- {{ $this->createForm }} -
- - Anulează - - Salvează + @endforeach + + @foreach ($rows as $row) +
+ +
+
{{ $row['name'] }}
+
{{ $row['meta'] }}
- +
+ @foreach ($days as $day) + @php + $cell = $matrix[$row['id']][$day['date']] ?? ['events' => [], 'load_hours' => 0, 'capacity' => $row['capacity_hours']]; + $loadClass = ''; + $cap = $cell['capacity'] ?: 10; + $loadRatio = $cap > 0 ? $cell['load_hours'] / $cap : 0; + if ($loadRatio >= 0.9) $loadClass = 'full'; + elseif ($loadRatio >= 0.5) $loadClass = 'warn'; + @endphp +
+ @if (! $day['is_closed']) + {{ rtrim(rtrim(number_format($cell['load_hours'], 1), '0'), '.') ?: '0' }}/{{ (int)$cap }} + @foreach ($cell['events'] as $e) +
+
{{ $e['client_name'] ?: $e['title'] }}
+
{{ $e['vehicle'] ?: '—' }}@if($e['plate']) · {{ $e['plate'] }}@endif
+
{{ $e['time'] }}@if($e['master_initial']) · {{ $e['master_initial'] }}@endif
+
+ @endforeach + + @endif +
+ @endforeach + @endforeach +
+
+ +
+

Legendă

+
+
+
Mecanici
+
+ @foreach ($this->getMasterOptions() as $id => $name) + @php + $u = \App\Models\Tenant\User::find($id); + $color = $u?->color ?: '#94a3b8'; + $spec = $u?->specialization ?: ''; + @endphp +
{{ $name }} @if($spec)· {{ $spec }} @endif
+ @endforeach +
+
+
+
Încărcare celulă
+
+
0–5h/10 liber/ușor
+
5–8.5h/10 mediu
+
≥9h/10 plin
+
+
+
+
Stare zi
+
+
Weekend
+
Închis (Duminică/sărbătoare)
+
Astăzi
+
+ +
+
+ @if ($openEvent) +
+
+
{{ $openEvent['title'] ?: $openEvent['client_name'] }}
+
{{ $openEvent['date'] }} · {{ $openEvent['time'] }} · {{ ucfirst($openEvent['status']) }}
+
+
+
+
+
+ @if ($openEvent['client_name'])
{{ $openEvent['client_name'] }}
@endif + @if ($openEvent['client_phone'])
{{ $openEvent['client_phone'] }}
@endif + @if ($openEvent['vehicle'])
{{ $openEvent['vehicle'] }}
@endif + @if ($openEvent['plate'])
{{ $openEvent['plate'] }}
@endif + @if ($openEvent['master_name'])
{{ $openEvent['master_name'] }}
@endif + @if ($openEvent['post_name'])
{{ $openEvent['post_name'] }}
@endif +
+ @if ($openEvent['notes']) +
{{ $openEvent['notes'] }}
+ @endif +
+
+ + ↗ Editare +
+ @endif +
+ +
+
+ @if ($showNewForm) +
+
+
Programare nouă
+
{{ $newAppt['date'] ?? '' }}
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+ + +
+ @endif +
+
diff --git a/tests/Feature/CalendarBoardV2Test.php b/tests/Feature/CalendarBoardV2Test.php new file mode 100644 index 0000000..f40b726 --- /dev/null +++ b/tests/Feature/CalendarBoardV2Test.php @@ -0,0 +1,184 @@ + 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $this->company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'cv-' . uniqid(), + 'name' => 'CV Co', 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($this->company); + $this->monday = Carbon::now()->startOfWeek()->toDateString(); + } + + public function test_get_days_returns_7_days_with_today_flagged(): void + { + $page = new CalendarBoard; + $page->weekStart = $this->monday; + $days = $page->getDays(); + + $this->assertCount(7, $days); + $this->assertEquals('Luni', $days[0]['name']); + $this->assertEquals('Duminică', $days[6]['name']); + $this->assertTrue($days[6]['is_closed']); + $this->assertTrue($days[5]['is_weekend']); + + $todayMatches = array_filter($days, fn ($d) => $d['date'] === today()->toDateString()); + $this->assertCount(1, $todayMatches); + } + + public function test_get_rows_returns_posts_when_grouped_by_post(): void + { + Post::create(['name' => 'Pod 1', 'color' => '#3B82F6', 'is_active' => true, 'hours_per_day' => 10]); + Post::create(['name' => 'Pod 2', 'color' => '#EF4444', 'is_active' => true, 'hours_per_day' => 8]); + + $page = new CalendarBoard; + $page->groupBy = 'post'; + $rows = $page->getRows(); + + $this->assertCount(2, $rows); + $this->assertEquals('post', $rows[0]['kind']); + $this->assertEquals('Pod 1', $rows[0]['name']); + $this->assertEquals(10.0, $rows[0]['capacity_hours']); + } + + public function test_get_rows_returns_masters_plus_fallback_when_grouped_by_master(): void + { + $user = User::create(['name' => 'Vasile I.', 'email' => 'v@example.com', 'password' => bcrypt('x'), 'role' => 'master', 'status' => 'active', 'color' => '#F59E0B', 'specialization' => 'Motor']); + + $page = new CalendarBoard; + $page->groupBy = 'master'; + $rows = $page->getRows(); + + $this->assertGreaterThanOrEqual(2, count($rows)); + // First is the user + $this->assertEquals('master', $rows[0]['kind']); + $this->assertEquals('Vasile I.', $rows[0]['name']); + $this->assertEquals('#F59E0B', $rows[0]['color']); + // Last is fallback "Fără maistru" + $this->assertEquals(0, end($rows)['id']); + } + + public function test_matrix_places_event_in_correct_cell_and_sums_load(): void + { + $post = Post::create(['name' => 'Pod 1', 'color' => '#3B82F6', 'is_active' => true, 'hours_per_day' => 10]); + $client = Client::create(['name' => 'C', 'phone' => '+37399000111', 'type' => 'individual', 'status' => 'active']); + Appointment::create([ + 'post_id' => $post->id, 'client_id' => $client->id, + 'date' => $this->monday, 'time_start' => '08:00', 'time_end' => '12:00', + 'title' => 'BMW frâne', 'status' => 'scheduled', + ]); + Appointment::create([ + 'post_id' => $post->id, 'client_id' => $client->id, + 'date' => $this->monday, 'time_start' => '13:00', 'time_end' => '17:30', + 'title' => 'VW ulei', 'status' => 'scheduled', + ]); + + $page = new CalendarBoard; + $page->weekStart = $this->monday; + $page->groupBy = 'post'; + $matrix = $page->getMatrix(); + + $this->assertCount(2, $matrix[$post->id][$this->monday]['events']); + // 4h + 4.5h = 8.5h + $this->assertEqualsWithDelta(8.5, $matrix[$post->id][$this->monday]['load_hours'], 0.01); + $this->assertEquals(10.0, $matrix[$post->id][$this->monday]['capacity']); + } + + public function test_move_event_reassigns_post_and_date(): void + { + $post1 = Post::create(['name' => 'Pod 1', 'color' => '#3B82F6', 'is_active' => true]); + $post2 = Post::create(['name' => 'Pod 2', 'color' => '#EF4444', 'is_active' => true]); + $a = Appointment::create([ + 'post_id' => $post1->id, + 'date' => $this->monday, 'time_start' => '09:00', 'time_end' => '10:00', + 'title' => 'Test', 'status' => 'scheduled', + ]); + + Livewire::test(CalendarBoard::class) + ->set('weekStart', $this->monday) + ->set('groupBy', 'post') + ->call('moveEvent', $a->id, $post2->id, Carbon::parse($this->monday)->addDays(2)->toDateString()); + + $a->refresh(); + $this->assertEquals($post2->id, $a->post_id); + $this->assertEquals(Carbon::parse($this->monday)->addDays(2)->toDateString(), $a->date->toDateString()); + } + + public function test_create_appt_inserts_appointment(): void + { + $post = Post::create(['name' => 'Pod 1', 'color' => '#3B82F6', 'is_active' => true]); + + Livewire::test(CalendarBoard::class) + ->call('openNewForm', $post->id, $this->monday) + ->set('newAppt.title', 'Schimb ulei') + ->set('newAppt.time_start', '10:00') + ->set('newAppt.time_end', '11:00') + ->call('createAppt') + ->assertSet('showNewForm', false); + + $appt = Appointment::where('title', 'Schimb ulei')->first(); + $this->assertNotNull($appt); + $this->assertEquals($post->id, $appt->post_id); + $this->assertEquals($this->monday, $appt->date->toDateString()); + } + + public function test_stats_compute_utilization_from_events(): void + { + $post = Post::create(['name' => 'Pod 1', 'color' => '#3B82F6', 'is_active' => true, 'hours_per_day' => 10]); + Appointment::create([ + 'post_id' => $post->id, + 'date' => $this->monday, 'time_start' => '08:00', 'time_end' => '16:00', + 'title' => 'X', 'status' => 'arrived', + ]); + + $page = new CalendarBoard; + $page->weekStart = $this->monday; + $stats = $page->getStats(); + + $this->assertEqualsWithDelta(8.0, $stats['scheduled_hours'], 0.01); + $this->assertEquals(60.0, $stats['capacity_hours']); // 10h × 6 days + $this->assertEquals(13, $stats['utilization_pct']); // 8/60 = 13% + $this->assertEquals(1, $stats['confirmed_count']); + } + + public function test_status_filter_narrows_events(): void + { + $post = Post::create(['name' => 'Pod 1', 'color' => '#3B82F6', 'is_active' => true]); + Appointment::create(['post_id' => $post->id, 'date' => $this->monday, 'time_start' => '09:00', 'time_end' => '10:00', 'title' => 'A', 'status' => 'scheduled']); + Appointment::create(['post_id' => $post->id, 'date' => $this->monday, 'time_start' => '10:00', 'time_end' => '11:00', 'title' => 'B', 'status' => 'arrived']); + + $page = new CalendarBoard; + $page->weekStart = $this->monday; + $page->statusFilter = 'confirmed'; + $matrix = $page->getMatrix(); + + $events = $matrix[$post->id][$this->monday]['events']; + $this->assertCount(1, $events); + $this->assertEquals('B', $events[0]['title']); + } +} diff --git a/tests/Feature/HiddenMarkupTest.php b/tests/Feature/HiddenMarkupTest.php new file mode 100644 index 0000000..0114dd0 --- /dev/null +++ b/tests/Feature/HiddenMarkupTest.php @@ -0,0 +1,56 @@ + 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create(['plan_id' => $plan->id, 'slug' => 'hm-' . uniqid(), 'name' => 'HM Co', 'status' => 'active']); + app(TenantManager::class)->setCurrent($company); + } + + public function test_internal_cost_applies_hidden_markup_percentage(): void + { + $part = Part::create([ + 'name' => 'Filtru ulei', 'article' => 'F-001', + 'buy_price' => 61.00, 'sell_price' => 85.00, + 'hidden_markup_pct' => 39.34, + ]); + + // 61 * (1 + 39.34/100) = 61 * 1.3934 = 85.00 (matches sell_price → margin invisible to customer) + $this->assertEquals(85.00, $part->internalCostWithHiddenMarkup()); + } + + public function test_internal_cost_falls_back_to_buy_price_when_no_hidden_markup(): void + { + $part = Part::create([ + 'name' => 'X', 'article' => 'X-1', + 'buy_price' => 100.00, 'sell_price' => 150.00, + ]); + + $this->assertEquals(100.00, $part->internalCostWithHiddenMarkup()); + } + + public function test_hidden_markup_persists_through_save_reload(): void + { + $part = Part::create([ + 'name' => 'Y', 'article' => 'Y-1', + 'buy_price' => 50, 'sell_price' => 80, + 'hidden_markup_pct' => 25.50, + ]); + $fresh = Part::find($part->id); + $this->assertEquals(25.50, (float) $fresh->hidden_markup_pct); + } +}