@extends('layouts.dashboard') @section('content') @php /** @var array $categories @var array $items @var array $ingredients @var array $itemIngredients @var array $schedules @var array $pager @var array|null $restaurant */ use App\Models\Ingredient; use App\Services\MenuSchedule; $catName = []; foreach ($categories as $c) { $catName[(int) $c['id']] = $c['name']; } $ingredients = $ingredients ?? []; $itemIngredients = $itemIngredients ?? []; $schedules = $schedules ?? []; $schedName = []; foreach ($schedules as $s) { $schedName[(int) $s['id']] = (string) $s['name']; } $summary = $summary ?? null; $allowance = $allowance ?? ['limit' => 0, 'used' => 0, 'unlimited' => true, 'remaining' => 0, 'reached' => false, 'plan_name' => '']; $filter = $filter ?? ''; $sort = $sort ?? 'menu'; $filterOpts = [ '' => t_raw('vendor.menu.filter_all'), 'available' => t_raw('vendor.menu.filter_available'), 'unavailable' => t_raw('vendor.menu.filter_unavailable'), 'popular' => t_raw('vendor.menu.filter_popular'), 'never_sold' => t_raw('vendor.menu.filter_never_sold'), 'no_cost' => t_raw('vendor.menu.filter_no_cost'), // The KPI links here, so the number is a job rather than a fact. 'uncategorised' => t_raw('vendor.menu.filter_uncategorised'), 'low_stock' => t_raw('vendor.menu.filter_low_stock'), ]; $sortOpts = [ 'menu' => t_raw('vendor.menu.sort_menu_order'), 'best' => t_raw('vendor.menu.sort_best_selling'), 'worst' => t_raw('vendor.menu.sort_worst_selling'), 'revenue' => t_raw('vendor.menu.sort_revenue'), 'margin' => t_raw('vendor.menu.sort_margin'), 'last_sold' => t_raw('vendor.menu.sort_last_sold'), // By the section's NAME, which is what a vendor recognises — "menu order" sorts by the id // the sections happened to be created in. 'category' => t_raw('vendor.menu.sort_category'), 'schedule' => t_raw('vendor.menu.sort_schedule'), 'name' => t_raw('vendor.menu.sort_name'), 'price' => t_raw('vendor.menu.sort_price'), // Where an import lands. Without it, "view the menu" after adding 200 dishes meant page 1 // of everything, with no way to see which dishes had just arrived. 'new' => t_raw('vendor.menu.sort_new'), ]; // Every control on the bar counts, not just the two that shipped first. The category and // schedule pickers narrow the list as well, and leaving them out of this flag told a vendor // whose chosen section simply holds no dishes "No menu items yet" — with an add-your-first-item // CTA — over a full menu, while Reset sat there looking as though nothing were applied. $hasMenuFilters = $filter !== '' || $sort !== 'menu' || (int) ($activeCategory ?? 0) > 0 || (int) ($activeSchedule ?? 0) > 0 || trim((string) ($search ?? '')) !== ''; // Quantities read as "3" rather than "3.00" — a kitchen counts in whole units far // more often than in fractions, and the decimals only appear when they are real. $fmtQty = static fn ($v): string => (rtrim(rtrim(number_format((float) $v, 2, '.', ''), '0'), '.') ?: '0'); $addBlocked = !$categories || $allowance['reached']; // Import is reached from the tab strip, not a button: it had one in the chrome AND one in the // page, which read as two different features. Adding a dish is an action ON this list, so it // lives in the list's own card head — the subheader carries no action at all here. $canImport = $canImport ?? (\App\Services\AiFeature::usable(\App\Services\AiFeature::MENU) || \App\Services\GoogleBusiness::enabled()); $catCounts = $catCounts ?? []; $draftCount = (int) ($draftCount ?? 0); $drafts = $drafts ?? []; $exportQs = (string) ($exportQs ?? ""); $sectionCount = count($categories); // Dragging writes the STORED order, so it is offered only while the list is showing it. Under // "best selling" or "price" the rows on screen are a reading of the menu, not its arrangement, // and a drop there would save that reading as the menu. A filter is fine — it narrows which rows // are on screen, and each one still keeps the slot it holds in the whole list. $orderIds = $orderIds ?? []; $reorderable = $sort === 'menu' && count($orderIds) > 1; $itemCols = $reorderable ? 7 : 6; @endphp @php /* .qm-d2: the dashboard density standard (app.css §35) */ @endphp
@php // Page primary action -> top-bar subheader slot, like every sibling list page. // Save order stays in the card head: it is table-scoped and wakes on drag. ob_start(); @endphp @if ($addBlocked) @else {{ t_raw('vendor.menu.add_item_btn') }} @endif @php \App\Support\View::slot('subhead', ob_get_clean()); @endphp @php /* What the menu is doing as a whole. Margin is the headline the price column can never show on its own, and "never sold" is the list every owner means to review and never has the number for. */ @endphp @if ($summary !== null)
@partial('kpi-card', ['variant' => 'panel', 'accent' => 'blue', 'icon' => 'fa-utensils', 'label' => t_raw('vendor.menu.kpi_items'), 'value' => number_format((int) $summary['items_c']), 'meta' => '' . ($allowance['unlimited'] ? t('vendor.menu.kpi_meta_items_unlimited', [':n' => number_format((int) $summary['available_c'])]) : t('vendor.menu.kpi_meta_items_allowance', [':used' => number_format((int) $allowance['used']), ':limit' => number_format((int) $allowance['limit']), ':plan' => (string) $allowance['plan_name']])) . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'green', 'icon' => 'fa-percent', 'label' => t_raw('vendor.menu.kpi_avg_margin'), 'value' => $summary['margin_avg'] !== null ? number_format((float) $summary['margin_avg'], 1) . '%' : "—", 'meta' => '' . t('vendor.menu.kpi_meta_margin', [':n' => number_format((int) $summary['no_cost_c'])]) . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'amber', 'icon' => 'fa-ghost', 'label' => t_raw('vendor.menu.kpi_never_sold'), 'value' => number_format((int) $summary['never_sold_c']), 'href' => route('vendor.menu', ['filter' => 'never_sold']), 'linkLabel' => t_raw('vendor.menu.kpi_link_review'), 'meta' => '' . t('vendor.menu.kpi_meta_never_sold') . '']) @php /* This card used to repeat the dish count: "Dishes 200" beside "Available 200", the same number twice in a five-card strip, while availability was said a third time as a table column. It now carries the one menu number nothing else surfaces — dishes a customer cannot reach because they have no category. Zero reads as calm; anything above it is a real job with a filter behind it. */ @endphp @php $uncat = (int) ($summary['uncategorised_c'] ?? 0); @endphp @partial('kpi-card', ['variant' => 'panel', 'accent' => $uncat > 0 ? 'amber' : 'teal', 'icon' => $uncat > 0 ? 'fa-folder-open' : 'fa-check-circle', 'label' => t_raw('vendor.menu.kpi_uncategorised'), 'value' => number_format($uncat), 'href' => $uncat > 0 ? route('vendor.menu', ['filter' => 'uncategorised']) : null, 'linkLabel' => $uncat > 0 ? t_raw('vendor.menu.kpi_link_review') : null, 'meta' => '' . t('vendor.menu.kpi_meta_uncategorised') . '']) @partial('kpi-card', ['variant' => 'panel', 'accent' => 'orange', 'icon' => 'fa-shopping-basket', 'label' => t_raw('vendor.menu.kpi_units'), 'value' => number_format((int) ($summary['units_c'] ?? 0)), 'meta' => '' . t('vendor.menu.kpi_meta_units') . ''])
@endif @if (!$allowance['unlimited'] && $allowance['reached'])

{{ t_raw('vendor.menu.allowance_reached_notice', [':plan' => (string) $allowance['plan_name'], ':limit' => number_format((int) $allowance['limit'])]) }}

@endif @php /* The import card used to sit here, between the KPIs and the list. Importing is done once; the dish list is why this page gets opened. Three hundred pixels of permanent real estate for a job a restaurant does in its first week put the daily work 753px down the page, every day, for ever. It lives on Import now — the tab whose badge already counts what it produces — which is also what stops "Import" meaning two different things in two places. */ @endphp @php /* The dishes ARE this page, full width. Sections and schedules moved to Structure: they are configuration touched once a month, and holding them here cost the dish list two screens of scroll. Add item carries no icon — the "+" is already in the label, as it is on 14 buttons across the product. */ @endphp

{{ t_raw('vendor.menu.items_title') }}

@php /* The arrangement, posted as one ordered list — every dish, not the ten on screen, so arranging a filtered page cannot disturb the rest of the menu. Its own form, because a row already carries a delete form and a form inside a form is dropped by the browser. Off until something moves. */ @endphp @if ($reorderable)
@csrf
@endif
@php /* Sort and filter, so the two questions this list exists to answer — what sells and what does not earn its place — can actually be asked. */ @endphp
@partial('filter-collapse')
@php /* The two things the menu is organised BY. A vendor with 27 sections could sort the list a dozen ways and still not answer "show me the pizzas" or "what do we serve at breakfast" — the questions the structure exists to make askable. Both narrow, and they compose with the filter beside them rather than replacing it. */ @endphp @if ($allCategories = ($allCategories ?? []))
@endif @if ($allSchedules = ($allSchedules ?? []))
@endif
@if ($reorderable)@endif @if ($items) @foreach ($items as $it) @php /* A dish is arranged INSIDE its section — data-order-group is what stops a drag carrying it out of one, since the section it belongs to is set on the dish form, not by where it sits in this list. */ @endphp @if ($reorderable) @endif @php /* Price and the margin it earns belong together: a price on its own cannot tell an owner whether the dish is worth cooking. */ @endphp @php $margin = $it['margin_pct'] ?? null; @endphp @php /* Units sold and when it last sold — the two facts that decide whether a dish stays on the menu. */ @endphp @php $units = (int) ($it['units_sold'] ?? 0); @endphp @php /* The countable stock behind the dish, linked straight to its ledger — a dish that is "available" but out of stock is the single most expensive thing a menu can be wrong about. */ @endphp @php $stockId = (int) ($it['stock_id'] ?? 0); @endphp @endforeach @else @php /* The empty state has to LEAD somewhere. A brand-new restaurant has no dishes and no sections, and sections now live on another page — so without a way through, the most important journey in the product is a dead end. A filtered list gets no action (the answer is to change the filter); no sections points at Structure; sections but no dishes points at the dish form. */ @endphp @partial('table-empty', [ 'colspan' => $itemCols, 'icon' => 'fa-utensils', 'msg' => $hasMenuFilters ? t('vendor.menu.empty_items_filtered') : ($categories ? t('vendor.menu.empty_items_with_categories') : t('vendor.menu.empty_items_no_categories')), 'cta' => $hasMenuFilters ? null : ($categories ? ['href' => route('vendor.menu.item.new'), 'label' => t_raw('vendor.menu.add_item_btn')] : ['href' => route('vendor.menu.structure'), 'label' => t_raw('vendor.menu.add_category_btn')]), ]) @endif
{{ t_raw('vendor.menu.reorder_label') }}{{ t_raw('vendor.menu.col_item') }}{{ t_raw('common.price') }}{{ t_raw('vendor.menu.col_sold') }}{{ t_raw('vendor.menu.col_stock') }}{{ t_raw('vendor.menu.available_label') }}{{ t_raw('common.actions') }}
@php $ingCount = count($itemIngredients[(int) $it['id']] ?? []); @endphp
{{ $it['name'] }} {!! !empty($it['is_popular']) ? '' . t('vendor.menu.popular_pill') . '' : '' !!}@if ($ingCount) {{ $ingCount }}@endif@if (!empty($catName[(int) $it['menu_category_id']])){{ $catName[(int) $it['menu_category_id']] }}@endif
{{ money($it['price']) }} @if ($margin !== null) @php $mTone = (float) $margin >= 60 ? 'green' : ((float) $margin >= 35 ? 'amber' : 'red'); @endphp
{{ t_raw('vendor.menu.margin_pill', [':pct' => number_format((float) $margin, 1)]) }}
{{ t_raw('vendor.menu.cost_line', [':amount' => money($it['cost'])]) }}
@else
{{ t_raw('vendor.menu.cost_missing') }}
@endif
@if ($units > 0) {{ t_raw('vendor.menu.units_value', [':n' => number_format($units)]) }}
{{ money($it['revenue']) }}
{!! t_raw('vendor.menu.last_sold_line', [':date' => fmt_day($it['last_sold'], true)]) !!}
@else {{ t_raw('vendor.menu.never_sold_pill') }} @endif
@if ($stockId === 0) {!! dash() !!} @else @php $onHand = (float) $it['stock_on_hand']; $lowAt = (float) $it['stock_low']; $sState = $onHand <= 0 ? 'red' : (($lowAt > 0 && $onHand <= $lowAt) ? 'amber' : 'green'); $sLabel = $onHand <= 0 ? t('vendor.inventory.state_out') : (($lowAt > 0 && $onHand <= $lowAt) ? t('vendor.inventory.state_low') : t('vendor.inventory.state_in_stock')); @endphp {!! $sLabel !!}
{!! $fmtQty($onHand) !!} {{ (string) $it['stock_unit'] }} · {{ (string) $it['stock_name'] }}
@if ((int) $it['stock_track'] !== 1)
{{ t_raw('vendor.menu.stock_not_tracked') }}
@endif @endif
@csrf
@csrf
@partial('table-toolbar', ['pager' => $pager, 'base' => '/vendor/menu'])
@php /* /.qm-d2 */ @endphp @endsection