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:
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user