@extends('layouts.dashboard') @section('content') @php /** * Vendor floor-plan / table tracking — the workspace edition. * * The floor is a zoomable, pannable STAGE (panzoom) holding a fixed-size room; * tables render as top-down furniture and are dragged with grid snapping * (interact.js) while layout editing is ON. Service mode (default) is * read-and-act: click a table for its schedule, cycle its status in place. * Editing happens in a live inspector panel, not a popup: select a table (or * stamp a new one from the shape palette) and the form beside the floor is it. * * The server contract is unchanged: /save (form POST, accepts grid_x/grid_y on * create), /position (AJAX drag saves), /status (AJAX cycle), /delete, zone * CRUD — all the same endpoints, payloads and language keys as before. * * @var array $zones Zone rows for this restaurant (tabs). * @var array $tables ALL table rows (every zone) - filtered per zone below. * @var int $activeZoneId Currently selected zone id (0 if none). * @var array $counts Status -> count map for the legend. */ use App\Models\Table; $statuses = Table::statuses(); // key => label // Tables grouped per zone so we can show per-tab counts and render the active zone. $byZone = []; foreach ($tables as $t) { $byZone[(int) $t['zone_id']][] = $t; } $activeTables = $byZone[$activeZoneId] ?? []; // Merge units: tables sharing a merge_group are one seating unit. Map each code to // its member labels (across every zone) so a card can name its joined partner(s) // and the timeline can flag joined rows — the connection is shown, not just implied. $mergeMembers = []; // code => [label, ...] foreach ($tables as $t) { $mg = (string) ($t['merge_group'] ?? ''); if ($mg !== '') { $mergeMembers[$mg][] = (string) $t['label']; } } $mergePartners = static function (string $code, string $self) use ($mergeMembers, $__env): array { return array_values(array_filter($mergeMembers[$code] ?? [], static fn ($l) => $l !== $self)); }; /** * Chair slots for the furniture rendering: a table draws as a top-down tabletop * with its chairs placed around it by shape + seat count (the way reservation * floor plans draw a room). Returns one entry per DRAWN chair — display capped * at 10 so a banquet table stays readable; the true count still prints on the top. * round -> ['a' => degrees] (radial placement) * square/rect-> ['side' => top|bottom|left|right, 'i' => n, 'of' => rowCount] */ $chairSlots = static function (string $shape, int $seats): array { $n = max(1, min(10, $seats)); if ($shape === 'round') { $out = []; for ($i = 0; $i < $n; $i++) { $out[] = ['a' => (int) round($i * 360 / $n)]; } return $out; } $rowCap = $shape === 'rectangle' ? 3 : 2; $top = $bottom = $ends = []; for ($i = 0; $i < $n; $i++) { if (count($top) < $rowCap && count($top) <= count($bottom)) { $top[] = 1; } elseif (count($bottom) < $rowCap) { $bottom[] = 1; } elseif (count($ends) < 2) { $ends[] = 1; } else { break; } } $slots = []; foreach ($top as $i => $unused) { $slots[] = ['side' => 'top', 'i' => $i, 'of' => count($top)]; } foreach ($bottom as $i => $unused) { $slots[] = ['side' => 'bottom', 'i' => $i, 'of' => count($bottom)]; } if (isset($ends[0])) { $slots[] = ['side' => 'left', 'i' => 0, 'of' => 1]; } if (isset($ends[1])) { $slots[] = ['side' => 'right', 'i' => 0, 'of' => 1]; } return $slots; }; // Room size: generous fixed logical canvas; existing grid_x/grid_y coordinates // land unchanged (same origin, same pixels — the stage merely zooms the view). $roomW = 2400; $roomH = 1400; @endphp @php /* .qm-d2: the dashboard density standard (app.css §35) */ @endphp
@php // Page primary action → top-bar subheader slot. In the workspace this enters // layout editing with the shape palette open, ready to stamp a table. ob_start(); @endphp @php \App\Support\View::slot('subhead', ob_get_clean()); @endphp @if (!count($zones))

{{ t_raw('vendor.tables.empty_zones_hint') }}

@else
@php /* -------------------------------------------------- left tool rail --- The editor chrome: shape stamps (always visible — clicking one arms the ghost and switches edit mode on itself), the layout toggle, and the zoom column. One column, icon-first, like every canvas tool. */ @endphp @php /* ------------------------------------------- context strip + stage --- */ @endphp
@foreach ($statuses as $key => $label) {{ $label }} {{ (int) ($counts[$key] ?? 0) }} @endforeach
{{ t_raw('vendor.tables.print_qr_btn') }}
@php /* dir="ltr": the room is a physical map; grid_x is a physical left offset and stays one for drag maths and RTL alike (see the old canvas note). */ @endphp
@foreach ($activeTables as $t) @php $tid = (int) $t['id']; $tStatus = $t['status']; $tLabel = $t['label']; $tSeats = (int) $t['seats']; $tZone = (int) $t['zone_id']; $tActive = (int) ($t['is_active'] ?? 1); $tMerge = (string) ($t['merge_group'] ?? ''); $tShape = (string) ($t['shape'] ?? 'square'); // The self-order kiosk mounted on this table (managed via the inspector). $tk = ($kioskByTable ?? [])[$tid] ?? null; $tkActive = $tk ? (int) $tk['is_active'] : 0; $tkUrl = $tk ? url('/restaurant/' . ($restaurant['slug'] ?? '') . '/kiosk?k=' . $tk['token']) : ''; // Today's booking for this table (occasion colour + tooltip), if any. $ev = ($eventByTable ?? [])[$tid] ?? null; $evColor = $ev ? ((string) ($ev['label_color'] ?? '') ?: '#64748b') : ''; $evTip = $ev ? (($ev['label_name'] ?? t_raw('vendor.tables.event_label_fallback')) . ' · ' . fmt_clock($ev['reserved_at']) . ' · ' . $ev['guest_name'] . ' (' . (int) $ev['party_size'] . ')') : ''; // Reservation correlation: today's booking timeline + derived state. $sched = ($scheduleByTable ?? [])[$tid] ?? null; $bookingCount = $sched ? count($sched['list']) : 0; $reservedSoon = !empty($sched['reservedSoon']) && $tStatus !== 'occupied'; $minsToNext = $sched['minutesToNext'] ?? null; $soonLabel = $reservedSoon ? ($minsToNext <= 0 ? t_raw('vendor.tables.due_now') : t_raw('vendor.tables.in_n_min', [':n' => (int) $minsToNext])) : ''; $cardAria = $tLabel . ', ' . $tSeats . ' ' . t_raw('vendor.pos.seats_suffix') . ', ' . ($statuses[$tStatus] ?? $tStatus) . ($tActive === 0 ? ', ' . t_raw('vendor.tables.aria_hidden_from_bookings') : '') . ($tMerge !== '' ? ', ' . t_raw('vendor.tables.aria_combined_with', [':code' => $tMerge]) : '') . ($ev ? ', ' . t_raw('vendor.tables.aria_booked_today') : '') . ($reservedSoon ? ', ' . t_raw('vendor.tables.aria_reserved_soon', [':when' => $soonLabel]) : ''); @endphp
@if ($reservedSoon)@endif @if ($ev)@endif @if ($tActive === 0)@endif
@php /* Furniture: the tabletop with chairs placed around it. */ @endphp @if ($tMerge !== '') @php $tPartners = $mergePartners($tMerge, $tLabel); @endphp {!! $tPartners ? e(implode('+', $tPartners)) : e($tMerge) !!}@endif
@endforeach
@php /* Inspector: a slide-over on the canvas edge — the table form itself, same fields, same action, same ids the old popup carried. Its shape tiles and seats stepper redraw the selected table's furniture live; grid_x/grid_y ride along so a stamped table lands where it was placed. */ @endphp
@php // ===================== TIMELINE VIEW (full-width reservation book) ===================== // Every table down the left, today's service window across the top, each table's // bookings as blocks placed by start time + sized by turn time. Empty track = free. $gwOpen = (int) ($serviceWindow['open'] ?? 600); $gwClose = (int) ($serviceWindow['close'] ?? 1440); $gwNow = (int) ($serviceWindow['now'] ?? 720); $gwSpan = max(60, $gwClose - $gwOpen); $gPpm = 2.0; // px per minute (wide enough that a full // service day fills the panel width) // A lead/trailing gutter so the first hour label clears the sticky "Table" column and // the last one isn't clipped at the right edge. $gLead = 26; $gTrack = (int) round($gwSpan * $gPpm) + 2 * $gLead; $gHour = static fn (int $min): string => fmt_date(qm_pattern('hour'), mktime(intdiv($min, 60) % 24, 0, 0, 1, 1, 2000)); $gPos = static fn (int $min) => (int) round(($min - $gwOpen) * $gPpm) + $gLead; // Block status → visual bucket (upcoming / seated / done / next-up). $gBucket = static function (array $b) use ($scheduleByTable, $__env): string { if (($b['status'] ?? '') === 'seated') { return 'seated'; } if (!empty($b['is_past']) || in_array($b['status'] ?? '', ['completed', 'no_show'], true)) { return 'past'; } return 'upcoming'; }; @endphp
{{ t_raw('vendor.tables.timeline_today') }}
@for ($m = (int) (ceil($gwOpen / 60) * 60); $m <= $gwClose; $m += 60) {!! $gHour($m) !!} @endfor
@foreach ($zones as $z) @php $zid = (int) $z['id']; $zt = $byZone[$zid] ?? []; if (!$zt) { continue; }; @endphp
{{ $z['name'] }} {{ count($zt) }}
@foreach ($zt as $gt) @php $gid = (int) $gt['id']; $gStatus = (string) $gt['status']; $gList = $scheduleByTable[$gid]['list'] ?? []; $gMerge = (string) ($gt['merge_group'] ?? ''); $gPartners = $gMerge !== '' ? $mergePartners($gMerge, (string) $gt['label']) : []; @endphp
{{ $gt['label'] }} {{ (int) $gt['seats'] }} @if ($gPartners) {{ implode('+', $gPartners) }}@endif
@for ($m = (int) (ceil($gwOpen / 60) * 60); $m <= $gwClose; $m += 60) @endfor @foreach ($gList as $b) @php $bStart = ((int) substr((string) $b['reserved_at'], 11, 2)) * 60 + (int) substr((string) $b['reserved_at'], 14, 2); $bLeft = $gPos($bStart); $bW = max(44, (int) round(((int) $b['duration']) * $gPpm)); $bTime = fmt_clock($b['reserved_at']); $bJoined = !empty($b['joined_group']); $bTip = $bTime . ' · ' . $b['guest_name'] . ' · ' . (int) $b['party_size'] . ' · ' . \App\Models\Reservation::statusLabel((string) $b['status']) . ($bJoined ? ' · ' . t_raw('vendor.tables.joined_unit', [':names' => (string) $b['joined_group']]) : ''); @endphp
@if ($bJoined) @endif{{ $bTime }} {{ (int) $b['party_size'] }} {{ $b['guest_name'] }}
@endforeach
@endforeach @endforeach @if ($gwNow >= $gwOpen && $gwNow <= $gwClose)
{{ t_raw('vendor.tables.sched_now_line') }}
@endif
@php // Today's booking timeline per table (JSON), read by qm-floorplan.js to fill the // schedule panel when a table is clicked. Today-only, so the payload is small. // Times are formatted server-side (fmt_date) so the client renders no dates itself. $schedJson = []; foreach (($scheduleByTable ?? []) as $sid => $srow) { $schedJson[$sid] = [ 'occupiedNow' => (bool) ($srow['occupiedNow'] ?? false), 'reservedSoon' => (bool) ($srow['reservedSoon'] ?? false), 'minutesToNext' => $srow['minutesToNext'] ?? null, 'nextId' => $srow['next']['id'] ?? null, 'mergeLabel' => $srow['mergeLabel'] ?? null, 'list' => array_map(static function (array $b): array { $hm = substr((string) $b['reserved_at'], 11, 5); $b['startMin'] = ((int) substr($hm, 0, 2)) * 60 + (int) substr($hm, 3, 2); $b['time'] = fmt_clock($b['reserved_at'], true); unset($b['reserved_at']); return $b; }, $srow['list'] ?? []), ]; } @endphp @php // JSON_HEX_TAG|JSON_HEX_AMP are required here: this block carries guest-supplied reservation // names, so without them a name containing would break out and inject into the dashboard. @endphp @php // Status ring the quick-cycle steps through — the model's own order. @endphp @php // i18n strings the schedule panel renders client-side (status labels reuse the // canonical Reservation::statusLabel, so the panel matches the reservations page). @endphp @endif
@csrf
@csrf
@php /* /.qm-d2 */ @endphp @endsection