'', 'color' => '#3b82f6', 'hours_per_day' => 10, 'description' => '']; public function getMaxContentWidth(): \Filament\Support\Enums\Width { return \Filament\Support\Enums\Width::Full; } public function getHeading(): string { return ''; } public function getSubheading(): ?string { return null; } public function mount(): void { $this->weekStart = Carbon::now()->startOfWeek()->toDateString(); } public function shiftWeek(int $deltaWeeks): void { // delta semantic depends on view mode $current = Carbon::parse($this->weekStart); $this->weekStart = match ($this->viewMode) { 'day' => $current->addDays($deltaWeeks)->toDateString(), 'month' => $current->addMonths($deltaWeeks)->startOfMonth()->toDateString(), default => $current->addWeeks($deltaWeeks)->toDateString(), }; } public function setWeekToday(): void { $this->weekStart = match ($this->viewMode) { 'day' => Carbon::today()->toDateString(), 'month' => Carbon::now()->startOfMonth()->toDateString(), default => Carbon::now()->startOfWeek()->toDateString(), }; } public function setGroupBy(string $g): void { $this->groupBy = in_array($g, ['post', 'master'], true) ? $g : 'post'; } public function setViewMode(string $m): void { if (! in_array($m, ['day', 'week', 'month', 'list', 'custom'], true)) return; $this->viewMode = $m; // Snap weekStart to a sensible anchor for the new view $this->weekStart = match ($m) { 'day' => Carbon::today()->toDateString(), 'month' => Carbon::parse($this->weekStart)->startOfMonth()->toDateString(), 'custom' => $this->customStart ?: Carbon::today()->toDateString(), default => Carbon::parse($this->weekStart)->startOfWeek()->toDateString(), }; } 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 day headers — count varies by view mode. */ public function getDays(): array { $today = Carbon::today()->toDateString(); $names = ['Luni', 'Marți', 'Miercuri', 'Joi', 'Vineri', 'Sâmbătă', 'Duminică']; $start = Carbon::parse($this->weekStart); $count = match ($this->viewMode) { 'day' => 1, 'month' => $start->daysInMonth, 'custom' => $this->customStart && $this->customEnd ? max(1, min(31, Carbon::parse($this->customStart)->diffInDays(Carbon::parse($this->customEnd)) + 1)) : 7, default => 7, }; if ($this->viewMode === 'month') { $start = Carbon::parse($this->weekStart)->startOfMonth(); } if ($this->viewMode === 'custom' && $this->customStart) { $start = Carbon::parse($this->customStart); } $days = []; for ($i = 0; $i < $count; $i++) { $d = $start->copy()->addDays($i); $dow = (int) $d->dayOfWeek; // 0=Sunday, 6=Saturday in Carbon $isoDow = (int) $d->isoWeekday(); // 1=Mon..7=Sun $days[] = [ 'date' => $d->toDateString(), 'label' => $d->format('d.m'), 'name' => $names[($isoDow - 1) % 7], 'is_today' => $d->toDateString() === $today, 'is_weekend' => $isoDow >= 6, 'is_closed' => $isoDow === 7, // 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' => $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; } 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; 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 openEvent(int $id): void { $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, ]; } public function closeEvent(): void { $this->openEventId = null; } public function deleteEvent(int $id): void { Appointment::where('id', $id)->delete(); $this->openEventId = null; Notification::make()->title('Programare ștearsă')->success()->send(); } public function openNewForm(int $rowId = 0, string $date = ''): void { $masterId = $this->groupBy === 'master' && $rowId ? $rowId : null; $postId = $this->groupBy === 'post' && $rowId ? $rowId : null; // Auto-fill default master from post if one is set if ($postId && ! $masterId) { $post = Post::find($postId); if ($post && $post->default_master_id) { $masterId = $post->default_master_id; } } $this->newAppt = [ 'date' => $date ?: today()->toDateString(), 'time_start' => '09:00', 'time_end' => '10:00', 'title' => '', 'client_id' => null, 'vehicle_id' => null, 'master_id' => $masterId, 'post_id' => $postId, 'notes' => '', ]; $this->showNewForm = true; $this->openEventId = null; } /** Quick-add post from calendar toolbar. */ public function openNewPostForm(): void { $this->showNewPostForm = true; $this->newPost = ['name' => '', 'color' => '#3b82f6', 'hours_per_day' => 10, 'description' => '']; } public function createPost(): void { $name = trim($this->newPost['name'] ?? ''); if ($name === '') { Notification::make()->title('Numele este obligatoriu')->danger()->send(); return; } Post::create([ 'name' => $name, 'color' => $this->newPost['color'] ?? '#3b82f6', 'hours_per_day' => (float) ($this->newPost['hours_per_day'] ?? 10), 'description' => trim($this->newPost['description'] ?? '') ?: null, 'is_active' => true, 'sort_order' => 100, ]); $this->showNewPostForm = false; Notification::make()->title('Spațiu de lucru adăugat')->success()->send(); } /** Inline rename + reassign default master from row label click. */ public function openRenamePost(int $postId): void { $post = Post::find($postId); if (! $post) return; $this->renamingPostId = $postId; $this->renamingPostName = $post->name; $this->renamingPostMasterId = $post->default_master_id; } public function saveRenamePost(): void { if (! $this->renamingPostId) return; $post = Post::find($this->renamingPostId); if (! $post) return; $name = trim($this->renamingPostName); if ($name === '') return; $post->update([ 'name' => $name, 'default_master_id' => $this->renamingPostMasterId ?: null, ]); $this->renamingPostId = null; Notification::make()->title('Post actualizat')->success()->send(); } /** Generate PDF for all appointments in the visible period. */ public function exportPdf() { $days = $this->getDays(); $firstDate = $days[0]['date'] ?? today()->toDateString(); $lastDate = end($days)['date'] ?? today()->toDateString(); $appointments = \App\Models\Tenant\Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name', 'post:id,name']) ->whereBetween('date', [$firstDate, $lastDate]) ->orderBy('date') ->orderBy('time_start') ->get(); $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.appointments', [ 'appointments' => $appointments->groupBy(fn ($a) => $a->date->toDateString()), 'periodLabel' => Carbon::parse($firstDate)->format('d.m.Y') . ' — ' . Carbon::parse($lastDate)->format('d.m.Y'), 'generatedAt' => now()->format('d.m.Y H:i'), ])->setPaper('a4', 'portrait'); return response()->streamDownload( fn () => print $pdf->output(), 'programari_' . $firstDate . '_' . $lastDate . '.pdf', ['Content-Type' => 'application/pdf'] ); } /** Flat list of appointments for the visible period — used by list view. */ public function getListAppointments(): array { $days = $this->getDays(); $firstDate = $days[0]['date'] ?? today()->toDateString(); $lastDate = end($days)['date'] ?? today()->toDateString(); return \App\Models\Tenant\Appointment::with(['client:id,name,phone', 'vehicle:id,plate,make,model', 'master:id,name', 'post:id,name']) ->whereBetween('date', [$firstDate, $lastDate]) ->when($this->masterFilter, fn ($q) => $q->where('master_id', $this->masterFilter)) ->orderBy('date') ->orderBy('time_start') ->get() ->map(fn ($a) => [ 'id' => $a->id, 'date' => $a->date->format('d.m.Y'), 'time' => substr($a->time_start ?? '', 0, 5) . '–' . substr($a->time_end ?? '', 0, 5), 'title' => $a->title, 'client_name' => $a->client?->name, 'client_phone' => $a->client?->phone, 'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')) . ' · ' . ($a->vehicle?->plate ?? '—'), 'master_name' => $a->master?->name ?? '—', 'post_name' => $a->post?->name ?? '—', 'status' => $a->status, ])->all(); } 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; } 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'); } }