Files
autocrm/app/Filament/Tenant/Pages/CalendarBoard.php
T
Vasyka 1d5ea6d261 feat: Calendar Vizual v2 (Pod×Days matrix) + hidden markup
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) <noreply@anthropic.com>
2026-06-04 21:50:22 +00:00

361 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
protected static ?string $navigationLabel = 'Calendar vizual';
protected static string|\UnitEnum|null $navigationGroup = 'CRM';
protected static ?int $navigationSort = 8;
protected static ?string $title = 'Calendar vizual';
protected string $view = 'filament.tenant.pages.calendar';
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 $newAppt = [];
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
{
$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');
}
}