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:
@@ -8,11 +8,8 @@ use App\Models\Tenant\Post;
|
|||||||
use App\Models\Tenant\User;
|
use App\Models\Tenant\User;
|
||||||
use App\Models\Tenant\Vehicle;
|
use App\Models\Tenant\Vehicle;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
|
|
||||||
class CalendarBoard extends Page
|
class CalendarBoard extends Page
|
||||||
{
|
{
|
||||||
@@ -24,147 +21,340 @@ class CalendarBoard extends Page
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 8;
|
protected static ?int $navigationSort = 8;
|
||||||
|
|
||||||
protected static ?string $title = 'Calendar';
|
protected static ?string $title = 'Calendar vizual';
|
||||||
|
|
||||||
protected string $view = 'filament.tenant.pages.calendar';
|
protected string $view = 'filament.tenant.pages.calendar';
|
||||||
|
|
||||||
public ?array $createData = [];
|
public string $weekStart; // 'Y-m-d' (Monday)
|
||||||
|
public string $groupBy = 'post'; // 'post' | 'master'
|
||||||
|
public ?int $masterFilter = null;
|
||||||
|
public string $statusFilter = 'all'; // all | confirmed | unconfirmed | in_work
|
||||||
|
public bool $showNewForm = false;
|
||||||
|
public ?int $openEventId = null;
|
||||||
|
|
||||||
public ?array $editData = [];
|
public array $newAppt = [];
|
||||||
|
|
||||||
public ?int $editId = null;
|
public function getMaxContentWidth(): \Filament\Support\Enums\Width
|
||||||
|
|
||||||
/** Register all forms used by this page (Filament v5 multi-form pattern). */
|
|
||||||
protected function getForms(): array
|
|
||||||
{
|
{
|
||||||
return ['createForm'];
|
return \Filament\Support\Enums\Width::Full;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEvents(string $start, string $end): array
|
public function getHeading(): string { return ''; }
|
||||||
|
public function getSubheading(): ?string { return null; }
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
{
|
{
|
||||||
return Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color'])
|
$this->weekStart = Carbon::now()->startOfWeek()->toDateString();
|
||||||
->whereBetween('date', [$start, $end])
|
}
|
||||||
->get()
|
|
||||||
->map(fn (Appointment $a) => [
|
public function shiftWeek(int $deltaWeeks): void
|
||||||
|
{
|
||||||
|
$this->weekStart = Carbon::parse($this->weekStart)->addWeeks($deltaWeeks)->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWeekToday(): void
|
||||||
|
{
|
||||||
|
$this->weekStart = Carbon::now()->startOfWeek()->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGroupBy(string $g): void
|
||||||
|
{
|
||||||
|
$this->groupBy = in_array($g, ['post', 'master'], true) ? $g : 'post';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatusFilter(string $s): void
|
||||||
|
{
|
||||||
|
$this->statusFilter = in_array($s, ['all', 'confirmed', 'unconfirmed', 'in_work'], true) ? $s : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMasterFilter($id): void
|
||||||
|
{
|
||||||
|
$this->masterFilter = $id ? (int) $id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the 7 day headers from weekStart. */
|
||||||
|
public function getDays(): array
|
||||||
|
{
|
||||||
|
$start = Carbon::parse($this->weekStart);
|
||||||
|
$today = Carbon::today()->toDateString();
|
||||||
|
$names = ['Luni', 'Marți', 'Miercuri', 'Joi', 'Vineri', 'Sâmbătă', 'Duminică'];
|
||||||
|
$days = [];
|
||||||
|
for ($i = 0; $i < 7; $i++) {
|
||||||
|
$d = $start->copy()->addDays($i);
|
||||||
|
$days[] = [
|
||||||
|
'date' => $d->toDateString(),
|
||||||
|
'label' => $d->format('d.m'),
|
||||||
|
'name' => $names[$i],
|
||||||
|
'is_today' => $d->toDateString() === $today,
|
||||||
|
'is_weekend' => $i >= 5,
|
||||||
|
'is_closed' => $i === 6, // Sunday default
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows: either Posts or active Masters depending on $groupBy. */
|
||||||
|
public function getRows(): array
|
||||||
|
{
|
||||||
|
if ($this->groupBy === 'master') {
|
||||||
|
$rows = User::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->whereNotNull('role')
|
||||||
|
->where(function ($q) { $q->where('role', 'master')->orWhere('role', 'mecanic')->orWhereNull('role'); })
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'color', 'specialization'])
|
||||||
|
->map(fn ($u) => [
|
||||||
|
'kind' => 'master',
|
||||||
|
'id' => $u->id,
|
||||||
|
'name' => $u->name,
|
||||||
|
'color' => $u->color ?: '#3b82f6',
|
||||||
|
'meta' => $u->specialization ?: '8h/zi',
|
||||||
|
'capacity_hours' => 8.0,
|
||||||
|
])->all();
|
||||||
|
// Always include "Fără maistru" row at the bottom
|
||||||
|
$rows[] = ['kind' => 'master', 'id' => 0, 'name' => 'Fără maistru', 'color' => '#94a3b8', 'meta' => '—', 'capacity_hours' => 0];
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = Post::where('is_active', true)->orderBy('sort_order')->orderBy('name')->get();
|
||||||
|
// Fallback: synthesize a default post if none configured yet
|
||||||
|
if ($posts->isEmpty()) {
|
||||||
|
return [
|
||||||
|
['kind' => 'post', 'id' => 0, 'name' => 'Pod 1 (default)', 'color' => '#3b82f6', 'meta' => '10h/zi', 'capacity_hours' => 10.0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $posts->map(fn ($p) => [
|
||||||
|
'kind' => 'post',
|
||||||
|
'id' => $p->id,
|
||||||
|
'name' => $p->name,
|
||||||
|
'color' => $p->color ?: '#3b82f6',
|
||||||
|
'meta' => ($p->hours_per_day ? $p->hours_per_day . 'h/zi' : '10h/zi') . ($p->description ? ' · ' . $p->description : ''),
|
||||||
|
'capacity_hours' => (float) ($p->hours_per_day ?: 10),
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns map [rowId][date] => ['events'=>[], 'load_hours'=>float, 'capacity'=>float] */
|
||||||
|
public function getMatrix(): array
|
||||||
|
{
|
||||||
|
$start = $this->weekStart;
|
||||||
|
$end = Carbon::parse($this->weekStart)->addDays(6)->toDateString();
|
||||||
|
|
||||||
|
$q = Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color'])
|
||||||
|
->whereBetween('date', [$start, $end]);
|
||||||
|
|
||||||
|
if ($this->masterFilter) {
|
||||||
|
$q->where('master_id', $this->masterFilter);
|
||||||
|
}
|
||||||
|
if ($this->statusFilter === 'confirmed') {
|
||||||
|
$q->where('status', 'arrived');
|
||||||
|
} elseif ($this->statusFilter === 'unconfirmed') {
|
||||||
|
$q->where('status', 'scheduled');
|
||||||
|
} elseif ($this->statusFilter === 'in_work') {
|
||||||
|
$q->where('status', 'in_work');
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = $q->get();
|
||||||
|
$rows = $this->getRows();
|
||||||
|
$days = $this->getDays();
|
||||||
|
|
||||||
|
$matrix = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
foreach ($days as $day) {
|
||||||
|
$matrix[$row['id']][$day['date']] = ['events' => [], 'load_hours' => 0, 'capacity' => $row['capacity_hours']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($events as $a) {
|
||||||
|
$rowId = $this->groupBy === 'post' ? ($a->post_id ?: ($rows[0]['id'] ?? 0)) : ($a->master_id ?: 0);
|
||||||
|
if (! isset($matrix[$rowId])) continue;
|
||||||
|
$date = $a->date->toDateString();
|
||||||
|
if (! isset($matrix[$rowId][$date])) continue;
|
||||||
|
|
||||||
|
$hours = $this->calcHours($a->time_start, $a->time_end);
|
||||||
|
$matrix[$rowId][$date]['load_hours'] += $hours;
|
||||||
|
$matrix[$rowId][$date]['events'][] = [
|
||||||
'id' => $a->id,
|
'id' => $a->id,
|
||||||
'title' => trim($a->title ?: ($a->client?->name ?? '—')),
|
'title' => $a->title ?: ($a->client?->name ?? '—'),
|
||||||
'start' => $a->date->format('Y-m-d') . 'T' . ($a->time_start ?? '08:00:00'),
|
'client_name' => $a->client?->name,
|
||||||
'end' => $a->date->format('Y-m-d') . 'T' . ($a->time_end ?? '09:00:00'),
|
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
||||||
'backgroundColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
'plate' => $a->vehicle?->plate,
|
||||||
'borderColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
'master_name' => $a->master?->name,
|
||||||
'extendedProps' => [
|
'master_initial' => $a->master ? strtoupper(mb_substr($a->master->name, 0, 1)) . '.' : '',
|
||||||
'client' => $a->client?->name,
|
'time' => substr($a->time_start ?? '', 0, 5) . '–' . substr($a->time_end ?? '', 0, 5),
|
||||||
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
'color' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
||||||
'plate' => $a->vehicle?->plate,
|
'status' => $a->status,
|
||||||
'master' => $a->master?->name,
|
];
|
||||||
'post' => $a->post?->name,
|
}
|
||||||
'status' => $a->status,
|
|
||||||
'notes' => $a->notes,
|
return $matrix;
|
||||||
],
|
|
||||||
])->all();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drag-drop reschedule. */
|
public function getStats(): array
|
||||||
public function moveEvent(int $id, string $start, string $end): void
|
{
|
||||||
|
$start = $this->weekStart;
|
||||||
|
$end = Carbon::parse($this->weekStart)->addDays(6)->toDateString();
|
||||||
|
$events = Appointment::whereBetween('date', [$start, $end])->get();
|
||||||
|
$rows = $this->getRows();
|
||||||
|
// Capacity = sum(rows.capacity_hours) * 6 working days
|
||||||
|
$capacity = 0;
|
||||||
|
foreach ($rows as $r) { $capacity += $r['capacity_hours'] * 6; }
|
||||||
|
$scheduled = 0;
|
||||||
|
foreach ($events as $a) {
|
||||||
|
$scheduled += $this->calcHours($a->time_start, $a->time_end);
|
||||||
|
}
|
||||||
|
$open = $events->whereNotIn('status', ['done', 'cancelled', 'no_show'])->count();
|
||||||
|
$confirmed = $events->whereIn('status', ['arrived', 'in_work', 'done'])->count();
|
||||||
|
$noShowAlert = $events
|
||||||
|
->where('status', 'scheduled')
|
||||||
|
->filter(fn ($a) => Carbon::parse($a->date->toDateString() . ' ' . ($a->time_start ?? '08:00'))->diffInHours(now(), false) > -24)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scheduled_hours' => round($scheduled, 1),
|
||||||
|
'capacity_hours' => round($capacity, 1),
|
||||||
|
'utilization_pct' => $capacity > 0 ? (int) round(100 * $scheduled / $capacity) : 0,
|
||||||
|
'open_count' => $open,
|
||||||
|
'confirmed_count' => $confirmed,
|
||||||
|
'total_count' => $events->count(),
|
||||||
|
'confirmation_rate_pct' => $events->count() > 0 ? (int) round(100 * $confirmed / $events->count()) : 0,
|
||||||
|
'no_show_alert' => $noShowAlert,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== mutations ==============
|
||||||
|
|
||||||
|
public function moveEvent(int $id, int $toRowId, string $toDate): void
|
||||||
{
|
{
|
||||||
$a = Appointment::find($id);
|
$a = Appointment::find($id);
|
||||||
if (! $a) return;
|
if (! $a) return;
|
||||||
|
if ($this->groupBy === 'post') {
|
||||||
[$startDate, $startTime] = $this->splitIso($start);
|
$a->post_id = $toRowId ?: null;
|
||||||
[, $endTime] = $this->splitIso($end);
|
} else {
|
||||||
|
$a->master_id = $toRowId ?: null;
|
||||||
$a->update([
|
}
|
||||||
'date' => $startDate,
|
$a->date = $toDate;
|
||||||
'time_start' => $startTime,
|
$a->save();
|
||||||
'time_end' => $endTime,
|
Notification::make()->title('Programare mutată')->body($a->title . ' → ' . $toDate)->success()->send();
|
||||||
]);
|
|
||||||
|
|
||||||
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
|
public function openEvent(int $id): void
|
||||||
{
|
{
|
||||||
$this->createData = [
|
$this->openEventId = $id;
|
||||||
'date' => substr($start, 0, 10),
|
$this->showNewForm = false;
|
||||||
'time_start' => substr($start, 11, 5),
|
}
|
||||||
'time_end' => substr($end, 11, 5),
|
|
||||||
|
public function getOpenEvent(): ?array
|
||||||
|
{
|
||||||
|
if (! $this->openEventId) return null;
|
||||||
|
$a = Appointment::with(['client', 'vehicle', 'master', 'post'])->find($this->openEventId);
|
||||||
|
if (! $a) return null;
|
||||||
|
return [
|
||||||
|
'id' => $a->id,
|
||||||
|
'title' => $a->title,
|
||||||
|
'status' => $a->status,
|
||||||
|
'date' => $a->date->format('d.m.Y'),
|
||||||
|
'time' => substr($a->time_start ?? '', 0, 5) . '–' . substr($a->time_end ?? '', 0, 5),
|
||||||
|
'client_name' => $a->client?->name,
|
||||||
|
'client_phone' => $a->client?->phone,
|
||||||
|
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
||||||
|
'plate' => $a->vehicle?->plate,
|
||||||
|
'master_name' => $a->master?->name,
|
||||||
|
'post_name' => $a->post?->name,
|
||||||
|
'notes' => $a->notes,
|
||||||
|
'deal_id' => $a->deal_id,
|
||||||
];
|
];
|
||||||
$this->createForm->fill($this->createData);
|
|
||||||
$this->dispatch('open-create-modal');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createForm(Schema $schema): Schema
|
public function closeEvent(): void
|
||||||
{
|
{
|
||||||
return $schema->components([
|
$this->openEventId = null;
|
||||||
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
|
public function deleteEvent(int $id): void
|
||||||
{
|
{
|
||||||
Appointment::where('id', $id)->delete();
|
Appointment::where('id', $id)->delete();
|
||||||
|
$this->openEventId = null;
|
||||||
Notification::make()->title('Programare ștearsă')->success()->send();
|
Notification::make()->title('Programare ștearsă')->success()->send();
|
||||||
$this->dispatch('events-changed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function splitIso(string $iso): array
|
public function openNewForm(int $rowId = 0, string $date = ''): void
|
||||||
{
|
{
|
||||||
// "2026-05-07T10:30:00" → ["2026-05-07", "10:30:00"]
|
$this->newAppt = [
|
||||||
if (str_contains($iso, 'T')) {
|
'date' => $date ?: today()->toDateString(),
|
||||||
return explode('T', $iso);
|
'time_start' => '09:00',
|
||||||
|
'time_end' => '10:00',
|
||||||
|
'title' => '',
|
||||||
|
'client_id' => null,
|
||||||
|
'vehicle_id' => null,
|
||||||
|
'master_id' => $this->groupBy === 'master' && $rowId ? $rowId : null,
|
||||||
|
'post_id' => $this->groupBy === 'post' && $rowId ? $rowId : null,
|
||||||
|
'notes' => '',
|
||||||
|
];
|
||||||
|
$this->showNewForm = true;
|
||||||
|
$this->openEventId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createAppt(): void
|
||||||
|
{
|
||||||
|
$d = $this->newAppt;
|
||||||
|
if (empty($d['title']) || empty($d['date'])) {
|
||||||
|
Notification::make()->title('Subiect și data sunt obligatorii')->danger()->send();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return [substr($iso, 0, 10), substr($iso, 11) ?: '08:00:00'];
|
Appointment::create([
|
||||||
|
'date' => $d['date'],
|
||||||
|
'time_start' => $d['time_start'] ?: '09:00',
|
||||||
|
'time_end' => $d['time_end'] ?: '10:00',
|
||||||
|
'title' => $d['title'],
|
||||||
|
'client_id' => $d['client_id'] ?: null,
|
||||||
|
'vehicle_id' => $d['vehicle_id'] ?: null,
|
||||||
|
'master_id' => $d['master_id'] ?: null,
|
||||||
|
'post_id' => $d['post_id'] ?: null,
|
||||||
|
'notes' => $d['notes'] ?: null,
|
||||||
|
'status' => 'scheduled',
|
||||||
|
]);
|
||||||
|
$this->showNewForm = false;
|
||||||
|
Notification::make()->title('Programare adăugată')->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMasterOptions(): array
|
||||||
|
{
|
||||||
|
return User::where('status', 'active')->pluck('name', 'id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPostOptions(): array
|
||||||
|
{
|
||||||
|
return Post::where('is_active', true)->pluck('name', 'id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClientOptions(): array
|
||||||
|
{
|
||||||
|
return Client::orderBy('name')->limit(50)->pluck('name', 'id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVehicleOptions(?int $clientId): array
|
||||||
|
{
|
||||||
|
if (! $clientId) return [];
|
||||||
|
return Vehicle::where('client_id', $clientId)->pluck('plate', 'id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcHours(?string $start, ?string $end): float
|
||||||
|
{
|
||||||
|
if (! $start || ! $end) return 1.0;
|
||||||
|
try {
|
||||||
|
$s = Carbon::createFromTimeString($start);
|
||||||
|
$e = Carbon::createFromTimeString($end);
|
||||||
|
$h = $e->floatDiffInHours($s);
|
||||||
|
return max(0, abs($h));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWeekLabel(): string
|
||||||
|
{
|
||||||
|
$s = Carbon::parse($this->weekStart);
|
||||||
|
$e = $s->copy()->addDays(6);
|
||||||
|
return $s->format('d.m') . ' — ' . $e->format('d.m.Y');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class Part extends Model implements HasMedia
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'company_id', 'name', 'article', 'brand', 'category',
|
'company_id', 'name', 'article', 'brand', 'category',
|
||||||
'qty', 'qty_reserved', 'unit', 'min_qty',
|
'qty', 'qty_reserved', 'unit', 'min_qty',
|
||||||
'buy_price', 'sell_price',
|
'buy_price', 'sell_price', 'hidden_markup_pct',
|
||||||
'location', 'barcode', 'preferred_supplier_id',
|
'location', 'barcode', 'preferred_supplier_id',
|
||||||
'is_active', 'is_published', 'notes',
|
'is_active', 'is_published', 'notes',
|
||||||
];
|
];
|
||||||
@@ -56,10 +56,19 @@ class Part extends Model implements HasMedia
|
|||||||
'min_qty' => 'decimal:2',
|
'min_qty' => 'decimal:2',
|
||||||
'buy_price' => 'decimal:2',
|
'buy_price' => 'decimal:2',
|
||||||
'sell_price' => 'decimal:2',
|
'sell_price' => 'decimal:2',
|
||||||
|
'hidden_markup_pct' => 'decimal:2',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'is_published' => 'boolean',
|
'is_published' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Internal cost+hidden markup (NOT shown to customer). Used for margin analytics + B2B contract pricing. */
|
||||||
|
public function internalCostWithHiddenMarkup(): float
|
||||||
|
{
|
||||||
|
$base = (float) $this->buy_price;
|
||||||
|
$pct = (float) ($this->hidden_markup_pct ?: 0);
|
||||||
|
return round($base * (1 + $pct / 100), 2);
|
||||||
|
}
|
||||||
|
|
||||||
public function preferredSupplier(): BelongsTo
|
public function preferredSupplier(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
|
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ class Post extends Model
|
|||||||
{
|
{
|
||||||
use BelongsToTenant;
|
use BelongsToTenant;
|
||||||
|
|
||||||
protected $fillable = ['company_id', 'name', 'color', 'is_active', 'sort_order'];
|
protected $fillable = ['company_id', 'name', 'color', 'is_active', 'sort_order', 'hours_per_day', 'description'];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
|
'hours_per_day' => 'decimal:1',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function appointments(): HasMany
|
public function appointments(): HasMany
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $t) {
|
||||||
|
if (! Schema::hasColumn('posts', 'hours_per_day')) {
|
||||||
|
$t->decimal('hours_per_day', 5, 1)->default(10)->after('color');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('posts', 'description')) {
|
||||||
|
$t->string('description', 255)->nullable()->after('hours_per_day');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('parts', function (Blueprint $t) {
|
||||||
|
if (! Schema::hasColumn('parts', 'hidden_markup_pct')) {
|
||||||
|
$t->decimal('hidden_markup_pct', 5, 2)->nullable()->after('sell_price');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $t) {
|
||||||
|
foreach (['hours_per_day', 'description'] as $col) {
|
||||||
|
if (Schema::hasColumn('posts', $col)) {
|
||||||
|
$t->dropColumn($col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Schema::table('parts', function (Blueprint $t) {
|
||||||
|
if (Schema::hasColumn('parts', 'hidden_markup_pct')) {
|
||||||
|
$t->dropColumn('hidden_markup_pct');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,143 +1,400 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
{{-- FullCalendar v6 ships CSS bundled in JS — no separate stylesheet needed --}}
|
@php
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
$days = $this->getDays();
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/locales/ro.global.min.js"></script>
|
$rows = $this->getRows();
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/locales/ru.global.min.js"></script>
|
$matrix = $this->getMatrix();
|
||||||
|
$stats = $this->getStats();
|
||||||
|
$openEvent = $this->getOpenEvent();
|
||||||
|
@endphp
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.cal-wrap {
|
:root {
|
||||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 10px;
|
--cv-bg: #F5F7FA;
|
||||||
padding: 16px;
|
--cv-surface: #FFFFFF;
|
||||||
}
|
--cv-border: #E2E8F0;
|
||||||
.dark .cal-wrap { background: #1f2937; border-color: #374151; }
|
--cv-text: #1A202C;
|
||||||
.fc { font-size: 13px; }
|
--cv-text-2: #4A5568;
|
||||||
.fc .fc-toolbar-title { font-size: 16px; }
|
--cv-text-3: #718096;
|
||||||
.fc-event { cursor: move; padding: 2px 4px; font-size: 11px; }
|
--cv-blue: #3B82F6;
|
||||||
.fc-event:hover { opacity: 0.85; }
|
--cv-blue-bg: #EBF5FF;
|
||||||
.fc-daygrid-event-dot { display: none; }
|
--cv-blue-text: #2563EB;
|
||||||
#cal-modal-bg {
|
--cv-green: #10B981;
|
||||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
--cv-green-text: #059669;
|
||||||
background: rgba(0,0,0,0.5); z-index: 9998;
|
--cv-orange: #D97706;
|
||||||
display: none; align-items: center; justify-content: center;
|
--cv-orange-bg: #FED7AA;
|
||||||
}
|
--cv-orange-text: #9A3412;
|
||||||
#cal-modal-bg.open { display: flex; }
|
--cv-red: #DC2626;
|
||||||
#cal-modal {
|
--cv-red-bg: #FECACA;
|
||||||
background: #fff; border-radius: 10px; padding: 20px;
|
--cv-red-text: #991B1B;
|
||||||
max-width: 520px; width: 90%; max-height: 88vh; overflow: auto;
|
--cv-row-bg: #F7FAFC;
|
||||||
z-index: 9999;
|
--cv-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
.dark #cal-modal { background: #1f2937; color: #f9fafb; }
|
.dark {
|
||||||
.cal-modal-head {
|
--cv-bg: #0f172a;
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
--cv-surface: #1f2937;
|
||||||
margin-bottom: 16px;
|
--cv-border: #374151;
|
||||||
}
|
--cv-text: #f1f5f9;
|
||||||
.cal-modal-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
--cv-text-2: #cbd5e1;
|
||||||
.cal-close {
|
--cv-text-3: #94a3b8;
|
||||||
background: none; border: none; font-size: 22px; cursor: pointer; color: #9ca3af;
|
--cv-row-bg: #111827;
|
||||||
}
|
--cv-blue-bg: #1e293b;
|
||||||
</style>
|
--cv-orange-bg: #422006;
|
||||||
|
--cv-red-bg: #450a0a;
|
||||||
|
}
|
||||||
|
|
||||||
<div
|
.fi-main-ctn:has(.cv-shell) { padding: 0 !important; }
|
||||||
x-data="{
|
.fi-main:has(.cv-shell) { padding: 0 !important; }
|
||||||
calendar: null,
|
.fi-page:has(.cv-shell) > div { padding: 0 !important; gap: 0 !important; }
|
||||||
init() {
|
.fi-page:has(.cv-shell) .fi-header { display: none !important; }
|
||||||
this.$nextTick(() => this.mount());
|
|
||||||
window.addEventListener('events-changed', () => this.calendar?.refetchEvents());
|
.cv-shell { background:var(--cv-bg); color:var(--cv-text); padding:24px; min-height:calc(100vh - 64px); font-size:13px; }
|
||||||
},
|
|
||||||
mount() {
|
.cv-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
|
||||||
const el = document.getElementById('autocrm-calendar');
|
.cv-head h1 { font-size:24px; font-weight:700; margin:0; }
|
||||||
if (!el) return;
|
.cv-breadcrumb { font-size:13px; color:var(--cv-text-3); margin-bottom:6px; }
|
||||||
this.calendar = new FullCalendar.Calendar(el, {
|
.cv-btn { padding:8px 14px; border:1px solid var(--cv-border); border-radius:6px; background:var(--cv-surface); cursor:pointer; font-size:14px; color:var(--cv-text); text-decoration:none; }
|
||||||
locale: 'ro',
|
.cv-btn:hover { background:var(--cv-row-bg); }
|
||||||
initialView: 'timeGridWeek',
|
.cv-btn-primary { background:var(--cv-blue); color:#fff !important; border-color:var(--cv-blue); }
|
||||||
firstDay: 1,
|
.cv-btn-primary:hover { background:#2563EB; }
|
||||||
height: 'auto',
|
.cv-btn-icon { padding:8px 10px; }
|
||||||
nowIndicator: true,
|
.cv-btn-group { display:flex; gap:8px; }
|
||||||
slotMinTime: '07:00:00',
|
|
||||||
slotMaxTime: '21:00:00',
|
/* KPI bar */
|
||||||
slotDuration: '00:30:00',
|
.cv-kpi-bar { display:grid; grid-template-columns:repeat(4, 1fr); gap:12px; margin-bottom:16px; }
|
||||||
headerToolbar: {
|
.cv-kpi { background:var(--cv-surface); padding:14px 16px; border-radius:8px; box-shadow:var(--cv-shadow); }
|
||||||
left: 'prev,next today',
|
.cv-kpi-label { font-size:11px; color:var(--cv-text-3); text-transform:uppercase; letter-spacing:.5px; font-weight:600; }
|
||||||
center: 'title',
|
.cv-kpi-value { font-size:22px; font-weight:700; margin-top:4px; }
|
||||||
right: 'timeGridDay,timeGridWeek,dayGridMonth'
|
.cv-kpi-sub { font-size:12px; color:var(--cv-text-3); margin-top:2px; }
|
||||||
},
|
.cv-kpi-value.green { color:var(--cv-green-text); }
|
||||||
buttonText: {
|
.cv-kpi-value.orange { color:var(--cv-orange); }
|
||||||
today: 'Azi', month: 'Lună', week: 'Săpt.', day: 'Zi'
|
.cv-kpi-value.red { color:var(--cv-red); }
|
||||||
},
|
|
||||||
editable: true,
|
/* Toolbar */
|
||||||
selectable: true,
|
.cv-toolbar { display:flex; gap:12px; align-items:center; background:var(--cv-surface); padding:12px 16px; border-radius:8px; box-shadow:var(--cv-shadow); margin-bottom:16px; flex-wrap:wrap; }
|
||||||
selectMirror: true,
|
.cv-toolbar-group { display:flex; gap:8px; align-items:center; }
|
||||||
eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
|
.cv-toolbar-label { font-size:12px; color:var(--cv-text-3); font-weight:500; text-transform:uppercase; letter-spacing:.5px; }
|
||||||
events: async (info, success, fail) => {
|
.cv-date { font-size:16px; font-weight:600; min-width:220px; text-align:center; }
|
||||||
try {
|
.cv-view-switcher { display:inline-flex; background:#EDF2F7; padding:3px; border-radius:6px; }
|
||||||
const events = await $wire.getEvents(
|
.dark .cv-view-switcher { background:#111827; }
|
||||||
info.startStr.substring(0, 10),
|
.cv-view-switcher button { padding:6px 14px; border:none; background:transparent; cursor:pointer; border-radius:4px; font-size:13px; font-weight:500; color:var(--cv-text-2); }
|
||||||
info.endStr.substring(0, 10)
|
.cv-view-switcher button.active { background:var(--cv-surface); color:var(--cv-text); box-shadow:0 1px 2px rgba(0,0,0,0.1); }
|
||||||
);
|
.cv-select { padding:8px 12px; border:1px solid var(--cv-border); border-radius:6px; background:var(--cv-surface); font-size:14px; color:var(--cv-text); }
|
||||||
success(events);
|
|
||||||
} catch (e) { fail(e); }
|
/* Notes */
|
||||||
},
|
.cv-notes { background:#FFFBEB; border:1px solid #FCD34D; padding:12px 14px; border-radius:6px; font-size:13px; color:#78350F; line-height:1.5; margin-bottom:16px; }
|
||||||
eventDrop: (info) => {
|
.dark .cv-notes { background:#422006; color:#fde68a; border-color:#854d0e; }
|
||||||
$wire.moveEvent(
|
.cv-notes strong { display:block; margin-bottom:4px; }
|
||||||
parseInt(info.event.id),
|
|
||||||
info.event.startStr,
|
/* Matrix */
|
||||||
info.event.endStr || info.event.startStr
|
.cv-matrix-wrap { background:var(--cv-surface); border-radius:8px; padding:16px; box-shadow:var(--cv-shadow); overflow-x:auto; }
|
||||||
);
|
.cv-grid { display:grid; grid-template-columns:140px repeat(7, minmax(160px, 1fr)); gap:1px; background:var(--cv-border); border-radius:6px; overflow:hidden; min-width:1240px; }
|
||||||
},
|
.cv-cell { background:var(--cv-surface); padding:10px 8px; min-height:90px; position:relative; }
|
||||||
eventResize: (info) => {
|
.cv-header-cell { background:var(--cv-row-bg); text-align:center; font-weight:600; font-size:13px; padding:10px; }
|
||||||
$wire.moveEvent(
|
.cv-day-name { display:block; font-size:11px; text-transform:uppercase; color:var(--cv-text-3); letter-spacing:.5px; }
|
||||||
parseInt(info.event.id),
|
.cv-day-num { display:block; font-size:18px; margin-top:2px; }
|
||||||
info.event.startStr,
|
.cv-header-cell.today { background:var(--cv-blue-bg); }
|
||||||
info.event.endStr
|
.cv-header-cell.today .cv-day-num { color:var(--cv-blue-text); }
|
||||||
);
|
.cv-header-cell.closed { opacity:0.6; }
|
||||||
},
|
|
||||||
select: (info) => {
|
.cv-row-label { background:var(--cv-row-bg); display:flex; align-items:center; gap:8px; font-weight:600; font-size:14px; padding:10px 12px; }
|
||||||
$wire.quickCreate(info.startStr, info.endStr);
|
.cv-row-color { width:12px; height:12px; border-radius:50%; flex-shrink:0; }
|
||||||
},
|
.cv-row-meta { font-size:11px; color:var(--cv-text-3); font-weight:400; margin-top:2px; }
|
||||||
eventClick: (info) => {
|
|
||||||
const e = info.event;
|
.cv-cell-body { cursor:pointer; }
|
||||||
const props = e.extendedProps;
|
.cv-cell-body:hover { background:var(--cv-row-bg); }
|
||||||
const ok = confirm(
|
.cv-cell-body.weekend { background:#FAFAFA; }
|
||||||
`${e.title}\n` +
|
.dark .cv-cell-body.weekend { background:#0a0f1c; }
|
||||||
`${props.vehicle ?? ''} ${props.plate ? '['+props.plate+']' : ''}\n` +
|
.cv-cell-body.closed { background:repeating-linear-gradient(45deg, #FAFAFA, #FAFAFA 4px, #F1F5F9 4px, #F1F5F9 8px); cursor:not-allowed; }
|
||||||
`Maistru: ${props.master ?? '—'}\n` +
|
.dark .cv-cell-body.closed { background:repeating-linear-gradient(45deg, #0a0f1c, #0a0f1c 4px, #111827 4px, #111827 8px); }
|
||||||
`Pod: ${props.post ?? '—'}\n\n` +
|
.cv-cell.over { background:var(--cv-blue-bg) !important; }
|
||||||
`Șterge programarea?`
|
|
||||||
);
|
.cv-load { position:absolute; top:4px; right:6px; font-size:10px; background:rgba(255,255,255,0.92); padding:1px 6px; border-radius:8px; color:var(--cv-text-2); font-weight:600; }
|
||||||
if (ok) $wire.deleteEvent(parseInt(e.id));
|
.dark .cv-load { background:rgba(0,0,0,0.5); color:var(--cv-text-2); }
|
||||||
}
|
.cv-load.warn { background:var(--cv-orange-bg); color:var(--cv-orange-text); }
|
||||||
});
|
.cv-load.full { background:var(--cv-red-bg); color:var(--cv-red-text); }
|
||||||
this.calendar.render();
|
|
||||||
}
|
.cv-event { background:#EBF5FF; border-left:3px solid var(--cv-blue); padding:6px 8px; margin-bottom:4px; border-radius:4px; cursor:grab; font-size:12px; transition:all .15s; }
|
||||||
}"
|
.cv-event:hover { transform:translateY(-1px); box-shadow:0 2px 6px rgba(0,0,0,0.08); }
|
||||||
x-on:open-create-modal.window="document.getElementById('cal-modal-bg').classList.add('open')"
|
.cv-event:active { cursor:grabbing; }
|
||||||
x-on:close-create-modal.window="document.getElementById('cal-modal-bg').classList.remove('open')"
|
.cv-event.dragging { opacity:0.4; }
|
||||||
>
|
.cv-event-title { font-weight:600; color:var(--cv-blue-text); }
|
||||||
{{-- wire:ignore: keep Livewire's DOM-morph away from the FullCalendar
|
.cv-event-meta { color:var(--cv-text-2); font-size:11px; margin-top:2px; }
|
||||||
subtree, otherwise the first $wire.getEvents response reverts the
|
.cv-event-time { font-weight:500; color:var(--cv-text); font-size:11px; margin-top:2px; }
|
||||||
container to its empty server HTML and the calendar collapses. --}}
|
|
||||||
<div class="cal-wrap" wire:ignore>
|
.cv-add { position:absolute; bottom:4px; right:4px; width:24px; height:24px; border-radius:50%; background:var(--cv-blue); color:white; border:none; cursor:pointer; font-size:16px; line-height:22px; opacity:0; transition:opacity .15s; box-shadow:0 2px 4px rgba(0,0,0,0.15); }
|
||||||
<div id="autocrm-calendar"></div>
|
.cv-cell:hover .cv-add { opacity:1; }
|
||||||
|
|
||||||
|
.cv-legend { background:var(--cv-surface); border-radius:8px; padding:16px; box-shadow:var(--cv-shadow); margin-top:16px; }
|
||||||
|
.cv-legend h3 { font-size:14px; margin-bottom:12px; color:var(--cv-text); }
|
||||||
|
.cv-legend-row { display:flex; gap:32px; flex-wrap:wrap; align-items:flex-start; }
|
||||||
|
.cv-legend-col { font-size:11px; color:var(--cv-text-3); text-transform:uppercase; margin-bottom:8px; font-weight:600; }
|
||||||
|
.cv-legend-items { display:flex; gap:16px; flex-wrap:wrap; font-size:12px; color:var(--cv-text-2); align-items:center; }
|
||||||
|
.cv-legend-item { display:inline-flex; gap:6px; align-items:center; }
|
||||||
|
.cv-legend-dot { width:12px; height:12px; border-radius:3px; }
|
||||||
|
|
||||||
|
/* Side panel */
|
||||||
|
.cv-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); z-index:100; opacity:0; pointer-events:none; transition:opacity .2s; }
|
||||||
|
.cv-overlay.open { opacity:1; pointer-events:all; }
|
||||||
|
.cv-panel { position:fixed; right:0; top:0; bottom:0; width:440px; max-width:92vw; background:var(--cv-surface); border-left:1px solid var(--cv-border); z-index:101; transform:translateX(100%); transition:transform .25s cubic-bezier(.4,0,.2,1); display:flex; flex-direction:column; }
|
||||||
|
.cv-panel.open { transform:translateX(0); }
|
||||||
|
.cv-panel-head { padding:16px 20px; border-bottom:1px solid var(--cv-border); display:flex; align-items:flex-start; justify-content:space-between; }
|
||||||
|
.cv-panel-title { font-size:15px; font-weight:600; }
|
||||||
|
.cv-panel-sub { font-size:11px; color:var(--cv-text-3); margin-top:2px; }
|
||||||
|
.cv-close { width:28px; height:28px; border-radius:6px; border:1px solid var(--cv-border); background:var(--cv-row-bg); cursor:pointer; display:flex; align-items:center; justify-content:center; }
|
||||||
|
.cv-panel-body { padding:16px 20px; flex:1; overflow-y:auto; }
|
||||||
|
.cv-pfield { margin-bottom:12px; }
|
||||||
|
.cv-pfield label { display:block; font-size:10px; font-weight:600; color:var(--cv-text-3); text-transform:uppercase; letter-spacing:.5px; margin-bottom:4px; }
|
||||||
|
.cv-pfield input, .cv-pfield textarea, .cv-pfield select { width:100%; padding:8px 10px; font-size:13px; border:1px solid var(--cv-border); border-radius:6px; background:var(--cv-surface); color:var(--cv-text); outline:none; font-family:inherit; }
|
||||||
|
.cv-pfield input:focus { border-color:var(--cv-blue); }
|
||||||
|
.cv-pfield-val { font-size:13px; color:var(--cv-text); }
|
||||||
|
.cv-panel-actions { padding:12px 20px; border-top:1px solid var(--cv-border); display:flex; gap:8px; }
|
||||||
|
.cv-panel-actions .cv-btn { flex:1; text-align:center; }
|
||||||
|
.cv-two-cols { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="cv-shell" x-data="{ dragEventId: null }">
|
||||||
|
<div class="cv-head">
|
||||||
|
<div>
|
||||||
|
<div class="cv-breadcrumb">CRM › Programări › <strong style="color:var(--cv-text);">Calendar vizual</strong></div>
|
||||||
|
<h1>Calendar vizual</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cv-btn-group">
|
||||||
|
<button class="cv-btn">⤓ Export</button>
|
||||||
|
<button class="cv-btn">🖨 Print</button>
|
||||||
|
<button class="cv-btn cv-btn-primary" wire:click="openNewForm">+ Programare nouă</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Quick-create modal --}}
|
<div class="cv-kpi-bar">
|
||||||
<div id="cal-modal-bg" @click.self="$el.classList.remove('open')">
|
<div class="cv-kpi">
|
||||||
<div id="cal-modal">
|
<div class="cv-kpi-label">Ore programate</div>
|
||||||
<div class="cal-modal-head">
|
<div class="cv-kpi-value">{{ $stats['scheduled_hours'] }} / {{ $stats['capacity_hours'] }}</div>
|
||||||
<h2>Programare nouă</h2>
|
<div class="cv-kpi-sub">săptămâna curentă · {{ $stats['utilization_pct'] }}% capacitate</div>
|
||||||
<button class="cal-close" @click="document.getElementById('cal-modal-bg').classList.remove('open')">×</button>
|
</div>
|
||||||
|
<div class="cv-kpi">
|
||||||
|
<div class="cv-kpi-label">Fișe deschise</div>
|
||||||
|
<div class="cv-kpi-value orange">{{ $stats['open_count'] }}</div>
|
||||||
|
<div class="cv-kpi-sub">programări active</div>
|
||||||
|
</div>
|
||||||
|
<div class="cv-kpi">
|
||||||
|
<div class="cv-kpi-label">Confirmate</div>
|
||||||
|
<div class="cv-kpi-value green">{{ $stats['confirmed_count'] }} / {{ $stats['total_count'] }}</div>
|
||||||
|
<div class="cv-kpi-sub">{{ $stats['confirmation_rate_pct'] }}% rata confirmare</div>
|
||||||
|
</div>
|
||||||
|
<div class="cv-kpi">
|
||||||
|
<div class="cv-kpi-label">No-show alert</div>
|
||||||
|
<div class="cv-kpi-value red">{{ $stats['no_show_alert'] }}</div>
|
||||||
|
<div class="cv-kpi-sub">programări neconfirmate < 24h</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cv-toolbar">
|
||||||
|
<div class="cv-toolbar-group">
|
||||||
|
<button class="cv-btn cv-btn-icon" wire:click="shiftWeek(-1)">◀</button>
|
||||||
|
<div class="cv-date">{{ $this->getWeekLabel() }}</div>
|
||||||
|
<button class="cv-btn cv-btn-icon" wire:click="shiftWeek(1)">▶</button>
|
||||||
|
<button class="cv-btn" wire:click="setWeekToday">Astăzi</button>
|
||||||
|
</div>
|
||||||
|
<div class="cv-toolbar-group">
|
||||||
|
<span class="cv-toolbar-label">Grupare:</span>
|
||||||
|
<div class="cv-view-switcher">
|
||||||
|
<button class="{{ $groupBy === 'post' ? 'active' : '' }}" wire:click="setGroupBy('post')">Pod</button>
|
||||||
|
<button class="{{ $groupBy === 'master' ? 'active' : '' }}" wire:click="setGroupBy('master')">Mecanic</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cv-toolbar-group" style="margin-left:auto;">
|
||||||
|
<span class="cv-toolbar-label">Filtru:</span>
|
||||||
|
<select class="cv-select" wire:change="setMasterFilter($event.target.value)">
|
||||||
|
<option value="">Toți mecanicii</option>
|
||||||
|
@foreach ($this->getMasterOptions() as $id => $name)
|
||||||
|
<option value="{{ $id }}" {{ $masterFilter == $id ? 'selected' : '' }}>{{ $name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<select class="cv-select" wire:change="setStatusFilter($event.target.value)">
|
||||||
|
<option value="all">Toate statusurile</option>
|
||||||
|
<option value="confirmed" @if($statusFilter==='confirmed')selected @endif>Confirmate</option>
|
||||||
|
<option value="unconfirmed" @if($statusFilter==='unconfirmed')selected @endif>Neconfirmate</option>
|
||||||
|
<option value="in_work" @if($statusFilter==='in_work')selected @endif>În lucru</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cv-notes">
|
||||||
|
<strong>⚙ Cum funcționează:</strong>
|
||||||
|
Matricea <strong>{{ $groupBy === 'post' ? 'Pod' : 'Mecanic' }} × Zile</strong>. Drag-and-drop între celule pentru a reprograma. Indicator de încărcare pe fiecare celulă (ore_planificate / capacitate). Click pe celulă goală → programare rapidă. Click pe eveniment → detalii.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cv-matrix-wrap">
|
||||||
|
<div class="cv-grid">
|
||||||
|
<div class="cv-cell cv-header-cell" style="background:#EDF2F7;">{{ $groupBy === 'post' ? 'Pod / Zi' : 'Mecanic / Zi' }}</div>
|
||||||
|
@foreach ($days as $day)
|
||||||
|
<div class="cv-cell cv-header-cell {{ $day['is_today'] ? 'today' : '' }} {{ $day['is_closed'] ? 'closed' : '' }}">
|
||||||
|
<span class="cv-day-name">{{ $day['name'] }}</span>
|
||||||
|
<span class="cv-day-num">{{ $day['label'] }}</span>
|
||||||
</div>
|
</div>
|
||||||
<form wire:submit="saveCreate">
|
@endforeach
|
||||||
{{ $this->createForm }}
|
|
||||||
<div style="margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end;">
|
@foreach ($rows as $row)
|
||||||
<x-filament::button color="gray" type="button"
|
<div class="cv-cell cv-row-label">
|
||||||
x-on:click="document.getElementById('cal-modal-bg').classList.remove('open')">
|
<span class="cv-row-color" style="background:{{ $row['color'] }}"></span>
|
||||||
Anulează
|
<div>
|
||||||
</x-filament::button>
|
<div>{{ $row['name'] }}</div>
|
||||||
<x-filament::button type="submit">Salvează</x-filament::button>
|
<div class="cv-row-meta">{{ $row['meta'] }}</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
@foreach ($days as $day)
|
||||||
|
@php
|
||||||
|
$cell = $matrix[$row['id']][$day['date']] ?? ['events' => [], 'load_hours' => 0, 'capacity' => $row['capacity_hours']];
|
||||||
|
$loadClass = '';
|
||||||
|
$cap = $cell['capacity'] ?: 10;
|
||||||
|
$loadRatio = $cap > 0 ? $cell['load_hours'] / $cap : 0;
|
||||||
|
if ($loadRatio >= 0.9) $loadClass = 'full';
|
||||||
|
elseif ($loadRatio >= 0.5) $loadClass = 'warn';
|
||||||
|
@endphp
|
||||||
|
<div class="cv-cell cv-cell-body {{ $day['is_weekend'] ? 'weekend' : '' }} {{ $day['is_closed'] ? 'closed' : '' }}"
|
||||||
|
@if (! $day['is_closed'])
|
||||||
|
@dragover.prevent="$el.classList.add('over')"
|
||||||
|
@dragleave="$el.classList.remove('over')"
|
||||||
|
@drop.prevent="
|
||||||
|
$el.classList.remove('over');
|
||||||
|
if (dragEventId) { $wire.moveEvent(dragEventId, {{ $row['id'] }}, '{{ $day['date'] }}'); dragEventId = null; }
|
||||||
|
"
|
||||||
|
@endif>
|
||||||
|
@if (! $day['is_closed'])
|
||||||
|
<span class="cv-load {{ $loadClass }}">{{ rtrim(rtrim(number_format($cell['load_hours'], 1), '0'), '.') ?: '0' }}/{{ (int)$cap }}</span>
|
||||||
|
@foreach ($cell['events'] as $e)
|
||||||
|
<div class="cv-event"
|
||||||
|
draggable="true"
|
||||||
|
style="background:{{ $e['color'] }}22; border-left-color:{{ $e['color'] }}"
|
||||||
|
wire:click="openEvent({{ $e['id'] }})"
|
||||||
|
@dragstart="dragEventId = {{ $e['id'] }}; $el.classList.add('dragging')"
|
||||||
|
@dragend="$el.classList.remove('dragging')">
|
||||||
|
<div class="cv-event-title" style="color:{{ $e['color'] }}">{{ $e['client_name'] ?: $e['title'] }}</div>
|
||||||
|
<div class="cv-event-meta">{{ $e['vehicle'] ?: '—' }}@if($e['plate']) · {{ $e['plate'] }}@endif</div>
|
||||||
|
<div class="cv-event-time">{{ $e['time'] }}@if($e['master_initial']) · {{ $e['master_initial'] }}@endif</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
<button class="cv-add" type="button" wire:click="openNewForm({{ $row['id'] }}, '{{ $day['date'] }}')">+</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cv-legend">
|
||||||
|
<h3>Legendă</h3>
|
||||||
|
<div class="cv-legend-row">
|
||||||
|
<div>
|
||||||
|
<div class="cv-legend-col">Mecanici</div>
|
||||||
|
<div class="cv-legend-items">
|
||||||
|
@foreach ($this->getMasterOptions() as $id => $name)
|
||||||
|
@php
|
||||||
|
$u = \App\Models\Tenant\User::find($id);
|
||||||
|
$color = $u?->color ?: '#94a3b8';
|
||||||
|
$spec = $u?->specialization ?: '';
|
||||||
|
@endphp
|
||||||
|
<div class="cv-legend-item"><span class="cv-legend-dot" style="background:{{ $color }}"></span> {{ $name }} @if($spec)· {{ $spec }} @endif</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="cv-legend-col">Încărcare celulă</div>
|
||||||
|
<div class="cv-legend-items">
|
||||||
|
<div class="cv-legend-item"><span class="cv-load" style="position:static;">0–5h/10</span> liber/ușor</div>
|
||||||
|
<div class="cv-legend-item"><span class="cv-load warn" style="position:static;">5–8.5h/10</span> mediu</div>
|
||||||
|
<div class="cv-legend-item"><span class="cv-load full" style="position:static;">≥9h/10</span> plin</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="cv-legend-col">Stare zi</div>
|
||||||
|
<div class="cv-legend-items">
|
||||||
|
<div class="cv-legend-item"><span class="cv-legend-dot" style="background:#FAFAFA; border:1px solid var(--cv-border);"></span> Weekend</div>
|
||||||
|
<div class="cv-legend-item"><span class="cv-legend-dot" style="background:repeating-linear-gradient(45deg,#FAFAFA,#FAFAFA 2px,#F1F5F9 2px,#F1F5F9 4px); border:1px solid var(--cv-border);"></span> Închis (Duminică/sărbătoare)</div>
|
||||||
|
<div class="cv-legend-item"><span class="cv-legend-dot" style="background:var(--cv-blue-bg); border:1px solid var(--cv-blue);"></span> Astăzi</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="cv-overlay {{ $openEventId ? 'open' : '' }}" wire:click="closeEvent"></div>
|
||||||
|
<div class="cv-panel {{ $openEventId ? 'open' : '' }}">
|
||||||
|
@if ($openEvent)
|
||||||
|
<div class="cv-panel-head">
|
||||||
|
<div>
|
||||||
|
<div class="cv-panel-title">{{ $openEvent['title'] ?: $openEvent['client_name'] }}</div>
|
||||||
|
<div class="cv-panel-sub">{{ $openEvent['date'] }} · {{ $openEvent['time'] }} · {{ ucfirst($openEvent['status']) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cv-close" wire:click="closeEvent">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="cv-panel-body">
|
||||||
|
<div class="cv-two-cols">
|
||||||
|
@if ($openEvent['client_name'])<div class="cv-pfield"><label>Client</label><div class="cv-pfield-val">{{ $openEvent['client_name'] }}</div></div>@endif
|
||||||
|
@if ($openEvent['client_phone'])<div class="cv-pfield"><label>Telefon</label><div class="cv-pfield-val" style="color:var(--cv-blue);">{{ $openEvent['client_phone'] }}</div></div>@endif
|
||||||
|
@if ($openEvent['vehicle'])<div class="cv-pfield"><label>Auto</label><div class="cv-pfield-val">{{ $openEvent['vehicle'] }}</div></div>@endif
|
||||||
|
@if ($openEvent['plate'])<div class="cv-pfield"><label>Numar</label><div class="cv-pfield-val">{{ $openEvent['plate'] }}</div></div>@endif
|
||||||
|
@if ($openEvent['master_name'])<div class="cv-pfield"><label>Maistru</label><div class="cv-pfield-val">{{ $openEvent['master_name'] }}</div></div>@endif
|
||||||
|
@if ($openEvent['post_name'])<div class="cv-pfield"><label>Pod</label><div class="cv-pfield-val">{{ $openEvent['post_name'] }}</div></div>@endif
|
||||||
|
</div>
|
||||||
|
@if ($openEvent['notes'])
|
||||||
|
<div class="cv-pfield"><label>Notițe</label><div class="cv-pfield-val" style="white-space:pre-wrap;">{{ $openEvent['notes'] }}</div></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="cv-panel-actions">
|
||||||
|
<button class="cv-btn" wire:click="deleteEvent({{ $openEvent['id'] }})" onclick="return confirm('Ștergi această programare?')">🗑 Șterge</button>
|
||||||
|
<a class="cv-btn cv-btn-primary" href="{{ route('filament.tenant.resources.appointments.edit', ['record' => $openEvent['id']]) }}">↗ Editare</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cv-overlay {{ $showNewForm ? 'open' : '' }}" wire:click="$set('showNewForm', false)"></div>
|
||||||
|
<div class="cv-panel {{ $showNewForm ? 'open' : '' }}">
|
||||||
|
@if ($showNewForm)
|
||||||
|
<div class="cv-panel-head">
|
||||||
|
<div>
|
||||||
|
<div class="cv-panel-title">Programare nouă</div>
|
||||||
|
<div class="cv-panel-sub">{{ $newAppt['date'] ?? '' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cv-close" wire:click="$set('showNewForm', false)">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="cv-panel-body">
|
||||||
|
<div class="cv-pfield"><label>Subiect *</label><input wire:model="newAppt.title" type="text" placeholder="Schimb ulei + filtru"></div>
|
||||||
|
<div class="cv-two-cols">
|
||||||
|
<div class="cv-pfield"><label>Data *</label><input wire:model="newAppt.date" type="date"></div>
|
||||||
|
<div class="cv-pfield"><label>Pod</label>
|
||||||
|
<select wire:model="newAppt.post_id">
|
||||||
|
<option value="">—</option>
|
||||||
|
@foreach ($this->getPostOptions() as $id => $name)
|
||||||
|
<option value="{{ $id }}">{{ $name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="cv-pfield"><label>De la</label><input wire:model="newAppt.time_start" type="time"></div>
|
||||||
|
<div class="cv-pfield"><label>Până la</label><input wire:model="newAppt.time_end" type="time"></div>
|
||||||
|
</div>
|
||||||
|
<div class="cv-pfield"><label>Client</label>
|
||||||
|
<select wire:model.live="newAppt.client_id">
|
||||||
|
<option value="">—</option>
|
||||||
|
@foreach ($this->getClientOptions() as $id => $name)
|
||||||
|
<option value="{{ $id }}">{{ $name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="cv-pfield"><label>Maistru</label>
|
||||||
|
<select wire:model="newAppt.master_id">
|
||||||
|
<option value="">—</option>
|
||||||
|
@foreach ($this->getMasterOptions() as $id => $name)
|
||||||
|
<option value="{{ $id }}">{{ $name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="cv-pfield"><label>Notițe</label><textarea wire:model="newAppt.notes" rows="3"></textarea></div>
|
||||||
|
</div>
|
||||||
|
<div class="cv-panel-actions">
|
||||||
|
<button class="cv-btn" wire:click="$set('showNewForm', false)">Anulează</button>
|
||||||
|
<button class="cv-btn cv-btn-primary" wire:click="createAppt">Salvează</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class HiddenMarkupTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||||
|
$company = Company::create(['plan_id' => $plan->id, 'slug' => 'hm-' . uniqid(), 'name' => 'HM Co', 'status' => 'active']);
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_internal_cost_applies_hidden_markup_percentage(): void
|
||||||
|
{
|
||||||
|
$part = Part::create([
|
||||||
|
'name' => 'Filtru ulei', 'article' => 'F-001',
|
||||||
|
'buy_price' => 61.00, 'sell_price' => 85.00,
|
||||||
|
'hidden_markup_pct' => 39.34,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 61 * (1 + 39.34/100) = 61 * 1.3934 = 85.00 (matches sell_price → margin invisible to customer)
|
||||||
|
$this->assertEquals(85.00, $part->internalCostWithHiddenMarkup());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_internal_cost_falls_back_to_buy_price_when_no_hidden_markup(): void
|
||||||
|
{
|
||||||
|
$part = Part::create([
|
||||||
|
'name' => 'X', 'article' => 'X-1',
|
||||||
|
'buy_price' => 100.00, 'sell_price' => 150.00,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(100.00, $part->internalCostWithHiddenMarkup());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hidden_markup_persists_through_save_reload(): void
|
||||||
|
{
|
||||||
|
$part = Part::create([
|
||||||
|
'name' => 'Y', 'article' => 'Y-1',
|
||||||
|
'buy_price' => 50, 'sell_price' => 80,
|
||||||
|
'hidden_markup_pct' => 25.50,
|
||||||
|
]);
|
||||||
|
$fresh = Part::find($part->id);
|
||||||
|
$this->assertEquals(25.50, (float) $fresh->hidden_markup_pct);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user