feat: calendar enhancements — view modes, post CRUD, PDF, list

Closes 5 user-requested features in /app/calendar-board:

1. View mode switcher: Zi / Săpt / Lună / Custom / Listă
2. Editable post names + assignable default master per bay
3. Quick-add bay (+ Pod nou) from calendar toolbar — supports yard
   spaces without a lift ("Curte 1", "Atelier electric")
4. PDF export of programări for printing
5. Inline list view alongside the matrix view

== View modes ==
$viewMode: day | week | month | custom | list

- Day view: 1 column, just today (or navigated day). Shift moves day by day.
- Week view: current 7-column matrix (unchanged default).
- Month view: 30/31 columns shown smaller (70px each). Shift moves by month.
- Custom: 2 date pickers for arbitrary start..end range (max 31 days).
- List view: flat sortable table with Data/Ora/Subiect/Client/Telefon/
  Auto/Pod/Maistru/Status columns. Click row → opens detail panel.

getDays() computes the right day count + start anchor for each mode.
setViewMode() snaps weekStart to the right anchor (startOfMonth, today,
startOfWeek). shiftWeek delta semantics adapt: day mode shifts 1 day,
month mode shifts 1 month, others shift 7 days.

== Editable posts + default master ==
New PostResource (/app/posts) in Admin group: full CRUD with name,
color, hours_per_day, default_master_id, description, is_active,
sort_order. Gated by ADMIN_SETTINGS_EDIT.

Migration: posts.default_master_id FK → users (nullOnDelete).

Inline rename from calendar: click any post's row label opens a modal
with name field + default master dropdown. Saved values propagate
immediately to next appointment creation.

Auto-fill in new appointment: when creating an appointment via the "+"
cell button on a post row, master_id is pre-filled from
post.default_master_id (if not already set by groupBy='master' row).

== Quick-add bay ==
"+ Pod nou" button in toolbar opens a small modal (no full page nav):
name, color picker, hours/day, description. createPost() saves and
refreshes the row list. Designed for "yard space" use-cases — names
like "Curte 1" or "Atelier electric" are first-class, not workarounds.

== PDF export ==
"🖨 PDF programări" button calls exportPdf() which uses the existing
dompdf integration (already installed). Renders pdf/appointments.blade.php
grouped by day with table per day showing time/title/client+vehicle/
post/master/status. Romanian date headers ("Marți, 10 Iunie 2026").
streamDownload with filename programari_YYYY-MM-DD_YYYY-MM-DD.pdf.

== List view ==
getListAppointments() returns flat array of all appointments in the
visible period (date-range respects current viewMode), with full
client/vehicle/post/master joined. Status filter respected. Row click
opens the existing event detail panel.

== Tests ==
CalendarEnhancementsTest (8):
- viewMode='day' returns 1 day
- viewMode='month' returns 30 days for June 2026
- viewMode='custom' uses customStart..customEnd range
- quick-add post via Livewire createPost persists with all fields
- rename post updates name + default_master_id
- new appointment auto-fills master_id from post's default_master_id
- list view returns flat array with phone + post name joined
- exportPdf returns StreamedResponse with .pdf filename

Suite: 285 passed (802 assertions). Was 277.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 07:34:27 +00:00
parent 2c66547967
commit 80c3834263
10 changed files with 712 additions and 16 deletions
+177
View File
@@ -0,0 +1,177 @@
<?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 CalendarEnhancementsTest extends TestCase
{
use RefreshDatabase;
private Company $company;
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' => 'ce-' . uniqid(), 'name' => 'CE', 'status' => 'active']);
app(TenantManager::class)->setCurrent($this->company);
}
public function test_view_mode_day_returns_one_day(): void
{
Carbon::setTestNow('2026-06-10 10:00:00');
$page = new CalendarBoard;
$page->weekStart = '2026-06-10';
$page->viewMode = 'day';
$days = $page->getDays();
$this->assertCount(1, $days);
$this->assertEquals('2026-06-10', $days[0]['date']);
Carbon::setTestNow();
}
public function test_view_mode_month_returns_30_or_31_days(): void
{
$page = new CalendarBoard;
$page->weekStart = '2026-06-01';
$page->viewMode = 'month';
$days = $page->getDays();
$this->assertCount(30, $days); // June has 30 days
$this->assertEquals('2026-06-01', $days[0]['date']);
$this->assertEquals('2026-06-30', $days[29]['date']);
}
public function test_view_mode_custom_uses_range(): void
{
$page = new CalendarBoard;
$page->weekStart = '2026-06-10';
$page->viewMode = 'custom';
$page->customStart = '2026-06-10';
$page->customEnd = '2026-06-13';
$days = $page->getDays();
$this->assertCount(4, $days);
$this->assertEquals('2026-06-10', $days[0]['date']);
$this->assertEquals('2026-06-13', $days[3]['date']);
}
public function test_create_post_inline_adds_to_calendar(): void
{
$admin = User::create(['name' => 'A', 'email' => 'a@e.com', 'password' => bcrypt('x'), 'role' => 'admin', 'status' => 'active']);
$this->actingAs($admin);
Livewire::test(CalendarBoard::class)
->call('openNewPostForm')
->set('newPost.name', 'Curte 1')
->set('newPost.color', '#ff0000')
->set('newPost.hours_per_day', 8)
->set('newPost.description', 'fără lift')
->call('createPost')
->assertSet('showNewPostForm', false);
$post = Post::where('name', 'Curte 1')->first();
$this->assertNotNull($post);
$this->assertEquals(8.0, (float) $post->hours_per_day);
$this->assertEquals('fără lift', $post->description);
}
public function test_rename_post_updates_name_and_default_master(): void
{
$admin = User::create(['name' => 'A', 'email' => 'a@e.com', 'password' => bcrypt('x'), 'role' => 'admin', 'status' => 'active']);
$master = User::create(['name' => 'Vasile', 'email' => 'v@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']);
$post = Post::create(['name' => 'Pod 1', 'color' => '#3b82f6', 'is_active' => true]);
$this->actingAs($admin);
Livewire::test(CalendarBoard::class)
->call('openRenamePost', $post->id)
->set('renamingPostName', 'Pod electric')
->set('renamingPostMasterId', $master->id)
->call('saveRenamePost')
->assertSet('renamingPostId', null);
$post->refresh();
$this->assertEquals('Pod electric', $post->name);
$this->assertEquals($master->id, $post->default_master_id);
}
public function test_new_appointment_autofills_master_from_post_default(): void
{
$master = User::create(['name' => 'Vasile', 'email' => 'v@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']);
$post = Post::create(['name' => 'Pod 1', 'color' => '#3b82f6', 'is_active' => true, 'default_master_id' => $master->id]);
Livewire::test(CalendarBoard::class)
->set('groupBy', 'post')
->call('openNewForm', $post->id, '2026-06-10');
$component = Livewire::test(CalendarBoard::class);
$component->set('groupBy', 'post')->call('openNewForm', $post->id, '2026-06-10');
// The newAppt.master_id should have been auto-set from post.default_master_id
$page = new CalendarBoard;
$page->groupBy = 'post';
$page->openNewForm($post->id, '2026-06-10');
$this->assertEquals($master->id, $page->newAppt['master_id']);
$this->assertEquals($post->id, $page->newAppt['post_id']);
}
public function test_list_view_returns_flat_appointments(): void
{
$client = Client::create(['name' => 'C', 'phone' => '+37399000000', 'type' => 'individual', 'status' => 'active']);
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'L-1']);
$post = Post::create(['name' => 'Pod 1', 'color' => '#3b82f6', 'is_active' => true]);
Appointment::create([
'post_id' => $post->id, 'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
'date' => '2026-06-10', 'time_start' => '10:00', 'time_end' => '11:00',
'title' => 'Schimb ulei', 'status' => 'scheduled',
]);
$page = new CalendarBoard;
$page->weekStart = '2026-06-08';
$page->viewMode = 'week';
$list = $page->getListAppointments();
$this->assertCount(1, $list);
$this->assertEquals('Schimb ulei', $list[0]['title']);
$this->assertEquals('C', $list[0]['client_name']);
$this->assertEquals('+37399000000', $list[0]['client_phone']);
$this->assertEquals('Pod 1', $list[0]['post_name']);
}
public function test_export_pdf_returns_streamed_pdf_response(): void
{
$client = Client::create(['name' => 'C', 'phone' => '+37399000000', 'type' => 'individual', 'status' => 'active']);
$post = Post::create(['name' => 'Pod 1', 'color' => '#3b82f6', 'is_active' => true]);
Appointment::create([
'post_id' => $post->id, 'client_id' => $client->id,
'date' => Carbon::now()->startOfWeek()->toDateString(),
'time_start' => '09:00', 'time_end' => '10:00',
'title' => 'Test PDF', 'status' => 'scheduled',
]);
$page = new CalendarBoard;
$page->mount();
$page->viewMode = 'week';
$response = $page->exportPdf();
// It's a StreamedResponse
$this->assertInstanceOf(\Symfony\Component\HttpFoundation\StreamedResponse::class, $response);
$cd = $response->headers->get('content-disposition');
$this->assertStringContainsString('programari_', $cd);
$this->assertStringEndsWith('.pdf', $cd);
}
}