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>
This commit is contained in:
2026-06-04 21:50:22 +00:00
parent d9b198a235
commit 1d5ea6d261
7 changed files with 986 additions and 247 deletions
+184
View File
@@ -0,0 +1,184 @@
<?php
namespace Tests\Feature;
use App\Filament\Tenant\Pages\CalendarBoard;
use App\Models\Central\Company;
use App\Models\Central\Plan;
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 App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
use Tests\TestCase;
class CalendarBoardV2Test extends TestCase
{
use RefreshDatabase;
private Company $company;
private string $monday;
protected function setUp(): void
{
parent::setUp();
$plan = Plan::firstOrCreate(['slug' => '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']);
}
}