Faza 1 (din lista de continuare): Calendar vizual cu FullCalendar 6
- Custom Filament Page CalendarBoard la /app/calendar-board (group CRM)
- FullCalendar 6.1.15 din CDN + locale RO
- View-uri: zi (timeGridDay), săptămână (timeGridWeek default), lună
- Programări colorate per maistru (din User.color)
- Live event loading: Livewire $wire.getEvents(start, end)
- Drag-drop reschedule: eventDrop → $wire.moveEvent(id, start, end)
- Resize event (extinde durată): eventResize
- Click slot gol → quick-create modal cu form Filament populat cu data/timpul
- Câmpuri: title, time_start/end, client (live), vehicle (filtrat după client),
master, post, notes
- Click event → confirm + delete
- Toolbar: prev/next/today + month/week/day switch
- 07:00–21:00 grid cu sloturi 30 min, today indicator live
- Modal stilizat (CSS scoped) cu close button + ESC
Total tenant routes: 93.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
<?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\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
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';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.calendar';
|
||||
|
||||
public ?array $createData = [];
|
||||
|
||||
public ?array $editData = [];
|
||||
|
||||
public ?int $editId = null;
|
||||
|
||||
public function getEvents(string $start, string $end): array
|
||||
{
|
||||
return Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color'])
|
||||
->whereBetween('date', [$start, $end])
|
||||
->get()
|
||||
->map(fn (Appointment $a) => [
|
||||
'id' => $a->id,
|
||||
'title' => trim($a->title ?: ($a->client?->name ?? '—')),
|
||||
'start' => $a->date->format('Y-m-d') . 'T' . ($a->time_start ?? '08:00:00'),
|
||||
'end' => $a->date->format('Y-m-d') . 'T' . ($a->time_end ?? '09:00:00'),
|
||||
'backgroundColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
||||
'borderColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
||||
'extendedProps' => [
|
||||
'client' => $a->client?->name,
|
||||
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
||||
'plate' => $a->vehicle?->plate,
|
||||
'master' => $a->master?->name,
|
||||
'post' => $a->post?->name,
|
||||
'status' => $a->status,
|
||||
'notes' => $a->notes,
|
||||
],
|
||||
])->all();
|
||||
}
|
||||
|
||||
/** Drag-drop reschedule. */
|
||||
public function moveEvent(int $id, string $start, string $end): void
|
||||
{
|
||||
$a = Appointment::find($id);
|
||||
if (! $a) return;
|
||||
|
||||
[$startDate, $startTime] = $this->splitIso($start);
|
||||
[, $endTime] = $this->splitIso($end);
|
||||
|
||||
$a->update([
|
||||
'date' => $startDate,
|
||||
'time_start' => $startTime,
|
||||
'time_end' => $endTime,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Programare mutată')
|
||||
->body($a->title . ' → ' . $startDate . ' ' . substr($startTime, 0, 5))
|
||||
->success()->send();
|
||||
|
||||
$this->dispatch('events-changed');
|
||||
}
|
||||
|
||||
public function quickCreate(string $start, string $end): void
|
||||
{
|
||||
$this->createData = [
|
||||
'date' => substr($start, 0, 10),
|
||||
'time_start' => substr($start, 11, 5),
|
||||
'time_end' => substr($end, 11, 5),
|
||||
];
|
||||
$this->createForm->fill($this->createData);
|
||||
$this->dispatch('open-create-modal');
|
||||
}
|
||||
|
||||
public function createForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\Hidden::make('date'),
|
||||
Forms\Components\TextInput::make('title')->label('Subiect')->required(),
|
||||
Schemas\Components\Section::make('Când')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TimePicker::make('time_start')->label('De la')->seconds(false)->required(),
|
||||
Forms\Components\TimePicker::make('time_end')->label('Până la')->seconds(false)->required(),
|
||||
]),
|
||||
Schemas\Components\Section::make('Cine')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Select::make('client_id')->label('Client')
|
||||
->options(fn () => Client::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->live(),
|
||||
Forms\Components\Select::make('vehicle_id')->label('Auto')
|
||||
->options(fn (\Filament\Schemas\Components\Utilities\Get $get) => $get('client_id')
|
||||
? Vehicle::where('client_id', $get('client_id'))->pluck('plate', 'id')
|
||||
: []),
|
||||
Forms\Components\Select::make('master_id')->label('Maistru')
|
||||
->options(fn () => User::where('status', 'active')->pluck('name', 'id'))
|
||||
->searchable(),
|
||||
Forms\Components\Select::make('post_id')->label('Pod')
|
||||
->options(fn () => Post::where('is_active', true)->pluck('name', 'id'))
|
||||
->searchable(),
|
||||
]),
|
||||
Forms\Components\Textarea::make('notes')->rows(2),
|
||||
])->statePath('createData');
|
||||
}
|
||||
|
||||
public function saveCreate(): void
|
||||
{
|
||||
$data = $this->createForm->getState();
|
||||
Appointment::create([
|
||||
'date' => $data['date'],
|
||||
'time_start' => $data['time_start'],
|
||||
'time_end' => $data['time_end'],
|
||||
'title' => $data['title'],
|
||||
'client_id' => $data['client_id'] ?? null,
|
||||
'vehicle_id' => $data['vehicle_id'] ?? null,
|
||||
'master_id' => $data['master_id'] ?? null,
|
||||
'post_id' => $data['post_id'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'status' => 'scheduled',
|
||||
]);
|
||||
$this->createData = [];
|
||||
Notification::make()->title('Programare adăugată')->success()->send();
|
||||
$this->dispatch('close-create-modal');
|
||||
$this->dispatch('events-changed');
|
||||
}
|
||||
|
||||
public function deleteEvent(int $id): void
|
||||
{
|
||||
Appointment::where('id', $id)->delete();
|
||||
Notification::make()->title('Programare ștearsă')->success()->send();
|
||||
$this->dispatch('events-changed');
|
||||
}
|
||||
|
||||
protected function splitIso(string $iso): array
|
||||
{
|
||||
// "2026-05-07T10:30:00" → ["2026-05-07", "10:30:00"]
|
||||
if (str_contains($iso, 'T')) {
|
||||
return explode('T', $iso);
|
||||
}
|
||||
return [substr($iso, 0, 10), substr($iso, 11) ?: '08:00:00'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<x-filament-panels::page>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/main.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/locales/ro.global.min.js"></script>
|
||||
|
||||
<style>
|
||||
.cal-wrap {
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.dark .cal-wrap { background: #1f2937; border-color: #374151; }
|
||||
.fc { font-size: 13px; }
|
||||
.fc .fc-toolbar-title { font-size: 16px; }
|
||||
.fc-event { cursor: move; padding: 2px 4px; font-size: 11px; }
|
||||
.fc-event:hover { opacity: 0.85; }
|
||||
.fc-daygrid-event-dot { display: none; }
|
||||
#cal-modal-bg {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5); z-index: 9998;
|
||||
display: none; align-items: center; justify-content: center;
|
||||
}
|
||||
#cal-modal-bg.open { display: flex; }
|
||||
#cal-modal {
|
||||
background: #fff; border-radius: 10px; padding: 20px;
|
||||
max-width: 520px; width: 90%; max-height: 88vh; overflow: auto;
|
||||
z-index: 9999;
|
||||
}
|
||||
.dark #cal-modal { background: #1f2937; color: #f9fafb; }
|
||||
.cal-modal-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cal-modal-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
.cal-close {
|
||||
background: none; border: none; font-size: 22px; cursor: pointer; color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
calendar: null,
|
||||
init() {
|
||||
this.$nextTick(() => this.mount());
|
||||
window.addEventListener('events-changed', () => this.calendar?.refetchEvents());
|
||||
},
|
||||
mount() {
|
||||
const el = document.getElementById('autocrm-calendar');
|
||||
if (!el) return;
|
||||
this.calendar = new FullCalendar.Calendar(el, {
|
||||
locale: 'ro',
|
||||
initialView: 'timeGridWeek',
|
||||
firstDay: 1,
|
||||
height: 'auto',
|
||||
nowIndicator: true,
|
||||
slotMinTime: '07:00:00',
|
||||
slotMaxTime: '21:00:00',
|
||||
slotDuration: '00:30:00',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'timeGridDay,timeGridWeek,dayGridMonth'
|
||||
},
|
||||
buttonText: {
|
||||
today: 'Azi', month: 'Lună', week: 'Săpt.', day: 'Zi'
|
||||
},
|
||||
editable: true,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
|
||||
events: async (info, success, fail) => {
|
||||
try {
|
||||
const events = await $wire.getEvents(
|
||||
info.startStr.substring(0, 10),
|
||||
info.endStr.substring(0, 10)
|
||||
);
|
||||
success(events);
|
||||
} catch (e) { fail(e); }
|
||||
},
|
||||
eventDrop: (info) => {
|
||||
$wire.moveEvent(
|
||||
parseInt(info.event.id),
|
||||
info.event.startStr,
|
||||
info.event.endStr || info.event.startStr
|
||||
);
|
||||
},
|
||||
eventResize: (info) => {
|
||||
$wire.moveEvent(
|
||||
parseInt(info.event.id),
|
||||
info.event.startStr,
|
||||
info.event.endStr
|
||||
);
|
||||
},
|
||||
select: (info) => {
|
||||
$wire.quickCreate(info.startStr, info.endStr);
|
||||
},
|
||||
eventClick: (info) => {
|
||||
const e = info.event;
|
||||
const props = e.extendedProps;
|
||||
const ok = confirm(
|
||||
`${e.title}\n` +
|
||||
`${props.vehicle ?? ''} ${props.plate ? '['+props.plate+']' : ''}\n` +
|
||||
`Maistru: ${props.master ?? '—'}\n` +
|
||||
`Pod: ${props.post ?? '—'}\n\n` +
|
||||
`Șterge programarea?`
|
||||
);
|
||||
if (ok) $wire.deleteEvent(parseInt(e.id));
|
||||
}
|
||||
});
|
||||
this.calendar.render();
|
||||
}
|
||||
}"
|
||||
x-on:open-create-modal.window="document.getElementById('cal-modal-bg').classList.add('open')"
|
||||
x-on:close-create-modal.window="document.getElementById('cal-modal-bg').classList.remove('open')"
|
||||
>
|
||||
<div class="cal-wrap">
|
||||
<div id="autocrm-calendar"></div>
|
||||
</div>
|
||||
|
||||
{{-- Quick-create modal --}}
|
||||
<div id="cal-modal-bg" @click.self="$el.classList.remove('open')">
|
||||
<div id="cal-modal">
|
||||
<div class="cal-modal-head">
|
||||
<h2>Programare nouă</h2>
|
||||
<button class="cal-close" @click="document.getElementById('cal-modal-bg').classList.remove('open')">×</button>
|
||||
</div>
|
||||
<form wire:submit="saveCreate">
|
||||
{{ $this->createForm }}
|
||||
<div style="margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<x-filament::button color="gray" type="button"
|
||||
x-on:click="document.getElementById('cal-modal-bg').classList.remove('open')">
|
||||
Anulează
|
||||
</x-filament::button>
|
||||
<x-filament::button type="submit">Salvează</x-filament::button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
Reference in New Issue
Block a user