95aeecb932
- LowStockTable: rename getHeading → getTableHeading (correct Filament API) - Global I18n::opts() helper wraps Model::CONST values in __() so Select dropdowns show translated status/type/season/etc. labels - Sed-transform ->options(X::CONST), ->options(array_combine(X::A,X::A)), and formatStateUsing(fn($s)=>X::CONST[$s]??$s) → wrap with __() (58 options + 10 combines + 7 formatStateUsing across 28 files) - CalendarBoard: all 7 day names now via __() (was missing 4) - calendar-board.blade: fix untranslated 'săptămâna curentă', 'capacitate', 'rata confirmare', 'mediu', 'plin', 'Pod / Zi' / 'Mecanic / Zi' - +40 RU/EN translations (Client & Auto, Maistru / Mecanic, Programat, Sosit, Finalizat, Anulat, Neprezentat, days of week, calendar KPIs) All 306 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
535 lines
20 KiB
PHP
535 lines
20 KiB
PHP
<?php
|
||
|
||
namespace App\Filament\Tenant\Pages;
|
||
|
||
use App\Models\Tenant\Appointment;
|
||
use App\Models\Tenant\Client;
|
||
use App\Models\Tenant\Post;
|
||
use App\Models\Tenant\User;
|
||
use App\Models\Tenant\Vehicle;
|
||
use Carbon\Carbon;
|
||
use Filament\Notifications\Notification;
|
||
use Filament\Pages\Page;
|
||
|
||
class CalendarBoard extends Page
|
||
{
|
||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-calendar-days';
|
||
|
||
public static function getNavigationLabel(): string
|
||
{
|
||
return __('nav.label.Calendar vizual');
|
||
}
|
||
|
||
public static function getNavigationGroup(): ?string
|
||
{
|
||
return __('nav.group.CRM');
|
||
}
|
||
|
||
protected static ?int $navigationSort = 8;
|
||
|
||
protected static ?string $title = null;
|
||
|
||
public function getTitle(): string
|
||
{
|
||
return __('Calendar vizual');
|
||
}
|
||
|
||
protected string $view = 'filament.tenant.pages.calendar';
|
||
|
||
public string $weekStart; // 'Y-m-d' (Monday)
|
||
public string $groupBy = 'post'; // 'post' | 'master'
|
||
public string $viewMode = 'week'; // day | week | month | list
|
||
public string $customStart = ''; // when viewMode='custom'
|
||
public string $customEnd = '';
|
||
public ?int $masterFilter = null;
|
||
public string $statusFilter = 'all'; // all | confirmed | unconfirmed | in_work
|
||
public bool $showNewForm = false;
|
||
public bool $showNewPostForm = false;
|
||
public ?int $openEventId = null;
|
||
public ?int $renamingPostId = null;
|
||
public string $renamingPostName = '';
|
||
public ?int $renamingPostMasterId = null;
|
||
|
||
public array $newAppt = [];
|
||
public array $newPost = ['name' => '', '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');
|
||
}
|
||
}
|