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
+173 -10
View File
@@ -27,12 +27,20 @@ class CalendarBoard extends Page
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
{
@@ -49,12 +57,22 @@ class CalendarBoard extends Page
public function shiftWeek(int $deltaWeeks): void
{
$this->weekStart = Carbon::parse($this->weekStart)->addWeeks($deltaWeeks)->toDateString();
// 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 = Carbon::now()->startOfWeek()->toDateString();
$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
@@ -62,6 +80,19 @@ class CalendarBoard extends Page
$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';
@@ -72,22 +103,41 @@ class CalendarBoard extends Page
$this->masterFilter = $id ? (int) $id : null;
}
/** Build the 7 day headers from weekStart. */
/** Build day headers — count varies by view mode. */
public function getDays(): array
{
$start = Carbon::parse($this->weekStart);
$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 < 7; $i++) {
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[$i],
'name' => $names[($isoDow - 1) % 7],
'is_today' => $d->toDateString() === $today,
'is_weekend' => $i >= 5,
'is_closed' => $i === 6, // Sunday default
'is_weekend' => $isoDow >= 6,
'is_closed' => $isoDow === 7, // Sunday default
];
}
return $days;
@@ -279,6 +329,17 @@ class CalendarBoard extends Page
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',
@@ -286,14 +347,116 @@ class CalendarBoard extends Page
'title' => '',
'client_id' => null,
'vehicle_id' => null,
'master_id' => $this->groupBy === 'master' && $rowId ? $rowId : null,
'post_id' => $this->groupBy === 'post' && $rowId ? $rowId : 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;
@@ -0,0 +1,103 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Auth\Permissions;
use App\Filament\Tenant\Resources\PostResource\Pages;
use App\Models\Tenant\Post;
use App\Models\Tenant\User;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class PostResource extends Resource
{
protected static ?string $model = Post::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static ?string $navigationLabel = 'Posturi de lucru';
protected static string|\UnitEnum|null $navigationGroup = 'Admin';
protected static ?string $modelLabel = 'pod';
protected static ?string $pluralModelLabel = 'posturi de lucru';
protected static ?int $navigationSort = 76;
public static function canViewAny(): bool
{
return auth()->user()?->canDo(Permissions::ADMIN_SETTINGS_EDIT) ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Pod / Spațiu lucru')
->columns(2)
->schema([
Forms\Components\TextInput::make('name')
->label('Nume')
->required()
->maxLength(80)
->placeholder('Ex: Pod 1, Curte 1, Atelier electric'),
Forms\Components\ColorPicker::make('color')
->default('#3b82f6'),
Forms\Components\TextInput::make('hours_per_day')
->label('Ore disponibile / zi')
->numeric()
->step(0.5)
->default(10)
->helperText('Capacitatea zilnică în ore'),
Forms\Components\Select::make('default_master_id')
->label('Mecanic implicit')
->options(fn () => User::where('status', 'active')->pluck('name', 'id'))
->searchable()
->placeholder('Niciun mecanic implicit')
->helperText('Va fi pre-completat când creezi o programare pentru acest pod'),
Forms\Components\TextInput::make('description')
->label('Descriere')
->maxLength(255)
->placeholder('Ex: cu lift, fără lift, doar diagnoză...')
->columnSpanFull(),
Forms\Components\TextInput::make('sort_order')
->numeric()
->default(100),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\ColorColumn::make('color'),
Tables\Columns\TextColumn::make('hours_per_day')->label('Ore/zi')->sortable(),
Tables\Columns\TextColumn::make('defaultMaster.name')->label('Mecanic implicit')->placeholder('—'),
Tables\Columns\TextColumn::make('description')->placeholder('—')->limit(40)->toggleable(),
Tables\Columns\TextColumn::make('appointments_count')->counts('appointments')->label('Programări')->badge(),
Tables\Columns\ToggleColumn::make('is_active')->label('Activ'),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->defaultSort('sort_order');
}
public static function getPages(): array
{
return [
'index' => Pages\ListPosts::route('/'),
'create' => Pages\CreatePost::route('/create'),
'edit' => Pages\EditPost::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\PostResource\Pages;
use App\Filament\Tenant\Resources\PostResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePost extends CreateRecord
{
protected static string $resource = PostResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\PostResource\Pages;
use App\Filament\Tenant\Resources\PostResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPost extends EditRecord
{
protected static string $resource = PostResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\PostResource\Pages;
use App\Filament\Tenant\Resources\PostResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPosts extends ListRecords
{
protected static string $resource = PostResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}