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:
2026-06-04 21:50:22 +00:00
parent d9b198a235
commit 1d5ea6d261
7 changed files with 986 additions and 247 deletions
@@ -1,143 +1,400 @@
<x-filament-panels::page>
{{-- FullCalendar v6 ships CSS bundled in JS no separate stylesheet needed --}}
<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>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/locales/ru.global.min.js"></script>
@php
$days = $this->getDays();
$rows = $this->getRows();
$matrix = $this->getMatrix();
$stats = $this->getStats();
$openEvent = $this->getOpenEvent();
@endphp
<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>
<style>
:root {
--cv-bg: #F5F7FA;
--cv-surface: #FFFFFF;
--cv-border: #E2E8F0;
--cv-text: #1A202C;
--cv-text-2: #4A5568;
--cv-text-3: #718096;
--cv-blue: #3B82F6;
--cv-blue-bg: #EBF5FF;
--cv-blue-text: #2563EB;
--cv-green: #10B981;
--cv-green-text: #059669;
--cv-orange: #D97706;
--cv-orange-bg: #FED7AA;
--cv-orange-text: #9A3412;
--cv-red: #DC2626;
--cv-red-bg: #FECACA;
--cv-red-text: #991B1B;
--cv-row-bg: #F7FAFC;
--cv-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.dark {
--cv-bg: #0f172a;
--cv-surface: #1f2937;
--cv-border: #374151;
--cv-text: #f1f5f9;
--cv-text-2: #cbd5e1;
--cv-text-3: #94a3b8;
--cv-row-bg: #111827;
--cv-blue-bg: #1e293b;
--cv-orange-bg: #422006;
--cv-red-bg: #450a0a;
}
<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')"
>
{{-- wire:ignore: keep Livewire's DOM-morph away from the FullCalendar
subtree, otherwise the first $wire.getEvents response reverts the
container to its empty server HTML and the calendar collapses. --}}
<div class="cal-wrap" wire:ignore>
<div id="autocrm-calendar"></div>
.fi-main-ctn:has(.cv-shell) { padding: 0 !important; }
.fi-main:has(.cv-shell) { padding: 0 !important; }
.fi-page:has(.cv-shell) > div { padding: 0 !important; gap: 0 !important; }
.fi-page:has(.cv-shell) .fi-header { display: none !important; }
.cv-shell { background:var(--cv-bg); color:var(--cv-text); padding:24px; min-height:calc(100vh - 64px); font-size:13px; }
.cv-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
.cv-head h1 { font-size:24px; font-weight:700; margin:0; }
.cv-breadcrumb { font-size:13px; color:var(--cv-text-3); margin-bottom:6px; }
.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; }
.cv-btn:hover { background:var(--cv-row-bg); }
.cv-btn-primary { background:var(--cv-blue); color:#fff !important; border-color:var(--cv-blue); }
.cv-btn-primary:hover { background:#2563EB; }
.cv-btn-icon { padding:8px 10px; }
.cv-btn-group { display:flex; gap:8px; }
/* KPI bar */
.cv-kpi-bar { display:grid; grid-template-columns:repeat(4, 1fr); gap:12px; margin-bottom:16px; }
.cv-kpi { background:var(--cv-surface); padding:14px 16px; border-radius:8px; box-shadow:var(--cv-shadow); }
.cv-kpi-label { font-size:11px; color:var(--cv-text-3); text-transform:uppercase; letter-spacing:.5px; font-weight:600; }
.cv-kpi-value { font-size:22px; font-weight:700; margin-top:4px; }
.cv-kpi-sub { font-size:12px; color:var(--cv-text-3); margin-top:2px; }
.cv-kpi-value.green { color:var(--cv-green-text); }
.cv-kpi-value.orange { color:var(--cv-orange); }
.cv-kpi-value.red { color:var(--cv-red); }
/* Toolbar */
.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; }
.cv-toolbar-group { display:flex; gap:8px; align-items:center; }
.cv-toolbar-label { font-size:12px; color:var(--cv-text-3); font-weight:500; text-transform:uppercase; letter-spacing:.5px; }
.cv-date { font-size:16px; font-weight:600; min-width:220px; text-align:center; }
.cv-view-switcher { display:inline-flex; background:#EDF2F7; padding:3px; border-radius:6px; }
.dark .cv-view-switcher { background:#111827; }
.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); }
.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); }
/* 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; }
.dark .cv-notes { background:#422006; color:#fde68a; border-color:#854d0e; }
.cv-notes strong { display:block; margin-bottom:4px; }
/* Matrix */
.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; }
.cv-header-cell { background:var(--cv-row-bg); text-align:center; font-weight:600; font-size:13px; padding:10px; }
.cv-day-name { display:block; font-size:11px; text-transform:uppercase; color:var(--cv-text-3); letter-spacing:.5px; }
.cv-day-num { display:block; font-size:18px; margin-top:2px; }
.cv-header-cell.today { background:var(--cv-blue-bg); }
.cv-header-cell.today .cv-day-num { color:var(--cv-blue-text); }
.cv-header-cell.closed { opacity:0.6; }
.cv-row-label { background:var(--cv-row-bg); display:flex; align-items:center; gap:8px; font-weight:600; font-size:14px; padding:10px 12px; }
.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; }
.cv-cell-body { cursor:pointer; }
.cv-cell-body:hover { background:var(--cv-row-bg); }
.cv-cell-body.weekend { background:#FAFAFA; }
.dark .cv-cell-body.weekend { background:#0a0f1c; }
.cv-cell-body.closed { background:repeating-linear-gradient(45deg, #FAFAFA, #FAFAFA 4px, #F1F5F9 4px, #F1F5F9 8px); cursor:not-allowed; }
.dark .cv-cell-body.closed { background:repeating-linear-gradient(45deg, #0a0f1c, #0a0f1c 4px, #111827 4px, #111827 8px); }
.cv-cell.over { background:var(--cv-blue-bg) !important; }
.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; }
.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); }
.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); }
.cv-event:active { cursor:grabbing; }
.cv-event.dragging { opacity:0.4; }
.cv-event-title { font-weight:600; color:var(--cv-blue-text); }
.cv-event-meta { color:var(--cv-text-2); font-size:11px; margin-top:2px; }
.cv-event-time { font-weight:500; color:var(--cv-text); font-size:11px; margin-top:2px; }
.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); }
.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 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 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 class="cv-kpi-bar">
<div class="cv-kpi">
<div class="cv-kpi-label">Ore programate</div>
<div class="cv-kpi-value">{{ $stats['scheduled_hours'] }} / {{ $stats['capacity_hours'] }}</div>
<div class="cv-kpi-sub">săptămâna curentă · {{ $stats['utilization_pct'] }}% capacitate</div>
</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 &lt; 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>
<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>
@endforeach
@foreach ($rows as $row)
<div class="cv-cell cv-row-label">
<span class="cv-row-color" style="background:{{ $row['color'] }}"></span>
<div>
<div>{{ $row['name'] }}</div>
<div class="cv-row-meta">{{ $row['meta'] }}</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;">05h/10</span> liber/ușor</div>
<div class="cv-legend-item"><span class="cv-load warn" style="position:static;">58.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 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>