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' => $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 { $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; } 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'); } }