@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
@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
@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
@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