@extends('layouts.dashboard') @section('content') @php /** * Admin Taxonomy manager — tabbed CRUD over the shared building blocks. * * One view serves two screens. Classifications holds the vocabularies a diner browses by * (cuisines, food types, store types and the groups that organise them); Menu holds the ones * that describe a dish (ingredients, their categories, allergens, attributes) and so mirrors * the vendor's own Menu section. The controller decides which four tabs a screen shows, so the * nav below is built from `$screenTabs` rather than being listed here twice. * * @var string $activeTab one of $screenTabs * @var array $screenTabs the four tab keys this screen shows, in order * @var string $screenBase '/admin/taxonomy' or '/admin/menu' * @var array $ingredientsBy ['proteins' => [rows], ...] (grouped, all) * @var array $ingredientCats slug => label, from `#__ingredient_categories` * @var array $cuisines cuisine rows (with restaurant_count) * @var array $tagsByGroup ['store_type' => [rows], 'dish_type' => [rows], ...] * @var array $tagGroups slug => label, from `#__tag_groups` * @var array $categoryRows ingredient-category rows (+ label, usage) * @var array $groupRows tag-group rows (+ label, usage) */ $tabs = [ 'ingredients' => t_raw('admin.taxonomy.tab_ingredients'), 'cuisines' => t_raw('admin.taxonomy.tab_cuisines'), 'food-types' => t_raw('admin.taxonomy.tab_food_types'), 'store-types' => t_raw('admin.taxonomy.tab_store_types'), 'categories' => t_raw('admin.taxonomy.tab_categories'), 'groups' => t_raw('admin.taxonomy.tab_groups'), 'allergens' => t_raw('admin.taxonomy.tab_allergens'), 'attributes' => t_raw('admin.taxonomy.tab_attributes'), ]; /* The two dimension tabs are the same screen twice — a list of slug + label + how many rows it classifies. Rendered from one description rather than two near-identical blocks, which is what let the four list tabs above drift apart in the first place. */ $dimensionTabs = [ 'categories' => [ 'rows' => $categoryRows ?? [], 'heading' => 'admin.taxonomy.heading_categories', 'add' => 'admin.taxonomy.btn_add_category', 'empty' => 'admin.taxonomy.empty_categories', 'confirm' => 'admin.taxonomy.confirm_delete_category', 'route' => 'category', 'icon' => 'fa-layer-group', ], 'attributes' => [ 'rows' => $attributeRows ?? [], 'heading' => 'admin.taxonomy.tab_attributes', 'add' => 'admin.taxonomy.btn_add_attribute', 'empty' => 'admin.taxonomy.empty_attributes', 'confirm' => 'admin.taxonomy.confirm_delete_attribute', 'route' => 'attribute', 'icon' => 'fa-star', ], 'allergens' => [ 'rows' => $allergenRows ?? [], 'heading' => 'admin.taxonomy.tab_allergens', 'add' => 'admin.taxonomy.btn_add_allergen', 'empty' => 'admin.taxonomy.empty_allergens', 'confirm' => 'admin.taxonomy.confirm_delete_allergen', 'route' => 'allergen', 'icon' => 'fa-exclamation-triangle', ], 'groups' => [ 'rows' => $groupRows ?? [], 'heading' => 'admin.taxonomy.heading_groups', 'add' => 'admin.taxonomy.btn_add_group', 'empty' => 'admin.taxonomy.empty_groups', 'confirm' => 'admin.taxonomy.confirm_delete_group', 'route' => 'group', 'icon' => 'fa-tags', ], ]; // Food-type groups exclude store_type (that has its own tab). $dishGroups = array_filter($tagGroups, static fn ($k) => $k !== 'store_type', ARRAY_FILTER_USE_KEY); // Tags for the two tag-driven tabs. $storeTags = $tagsByGroup['store_type'] ?? []; $dishTags = []; foreach ($tagsByGroup as $g => $rows) { if ($g === 'store_type') { continue; } foreach ($rows as $row) { $dishTags[] = $row; } } // Resolve a taxonomy row's artwork dynamically, so every row shows its OWN: // 1) an image path the admin chose (media library or a shipped asset), else // 2) a glossy 3D image keyed by slug (store types + dish types ship these), else // 3) the seeded FontAwesome glyph. // public_path(), not dirname(__DIR__, 3): assets live under public/, and the app root has no // assets/ at all. This screen was the one place using the latter, so $has3d was always empty // and the admin list fell through to glyphs while the storefront - which globs public_path() // in cuisine-card, side-panel and hero-cuisine - showed the 3D artwork for the same rows. $has3d = []; foreach (glob(public_path() . '/assets/client/icons3d/*.png') ?: [] as $p) { $has3d[basename($p, '.png')] = true; } // The "Font Awesome" source draws from the one shared library — see partials/icon-library.php. // This screen's own curated list was folded into FieldOptions::ICONS so there is a single // place to add a glyph, and every picker in the product gains it at once. $renderIcon = static function (?string $icon, string $slug = '') use ($has3d, $__env): string { // 1) An explicit choice the admin made — an uploaded image OR a chosen built-in // library icon (a real path, never a FontAwesome class). This wins over the // automatic slug default so the picked icon/image is always honoured. if ($icon !== null && $icon !== '' && !str_starts_with($icon, 'fa-')) { if (preg_match('#/(icons3d|svg|flags)/#', $icon)) { return ''; } return ''; } // 2) No explicit image: a glossy 3D icon keyed by the row's own slug, when we ship one. if ($slug !== '' && !empty($has3d[$slug])) { return ''; } // 3) The seeded FontAwesome glyph. if ($icon !== null && $icon !== '' && str_starts_with($icon, 'fa-')) { return ''; } return ''; }; // The effective icon VALUE that $renderIcon displays, so the edit picker opens on // exactly what's shown next to the title: a glossy 3D icon keyed by slug wins over a // seeded FontAwesome fallback (otherwise "Pizza" shows a 3D pizza but edits to fa-pizza-slice). $resolveIcon = static function (?string $icon, string $slug = '') use ($has3d, $__env): string { if ($icon !== null && $icon !== '' && !str_starts_with($icon, 'fa-')) { return $icon; // an explicit image / library path the admin chose } if ($slug !== '' && !empty($has3d[$slug])) { return 'assets/client/icons3d/' . $slug . '.png'; // glossy 3D icon keyed by slug } return (string) ($icon ?? ''); // the seeded FontAwesome glyph (or nothing) }; $totalIngredients = 0; foreach ($ingredientsBy as $rows) { $totalIngredients += count($rows); } /* * Paging. Only one tab renders at a time, so the four lists share the single ?page / * ?per_page pair without ever competing for it — a tab is one list to the reader, and one * page size to the page. The controller hands over the complete taxonomy (it is small and * every tab needs its own totals for the tab badges), so the page is a slice taken here. * * Ingredients are one list presented under category headings, not eight lists: they are * flattened, sliced, then regrouped, so a page can end mid-category and the footer speaks * for the whole library rather than for whichever heading it happens to sit under. */ $flatIngredients = []; foreach ($ingredientsBy as $catKey => $rows) { foreach ($rows as $row) { $flatIngredients[] = [$catKey, $row]; } } /* * "In use: 6" on a dimension row is also the number that REFUSES its delete, so it has to be able * to answer which six. Each count links here with its slug, and the list narrows to it — applied * before the pager so the footer counts the filtered set, not the whole library. Clearing it is * the sidebar entry itself, which carries no query. */ $catFilter = trim((string) (is_scalar($c = request()->query('cat')) ? $c : '')); $grpFilter = trim((string) (is_scalar($g = request()->query('grp')) ? $g : '')); if ($catFilter !== '') { $flatIngredients = array_values(array_filter($flatIngredients, static fn ($p) => $p[0] === $catFilter)); } if ($grpFilter !== '') { $dishTags = array_values(array_filter($dishTags, static fn ($r) => (string) ($r['group'] ?? '') === $grpFilter)); $storeTags = array_values(array_filter($storeTags, static fn ($r) => (string) ($r['group'] ?? '') === $grpFilter)); } $pager = match ($activeTab) { 'cuisines' => paginate(count($cuisines), 20), 'food-types' => paginate(count($dishTags), 20), 'store-types' => paginate(count($storeTags), 20), default => paginate(count($flatIngredients), 20), }; $cuisines = collect($cuisines)->all(); $cuisinesPage = array_slice($cuisines, $pager['offset'], $pager['perPage']); $dishTagsPage = array_slice($dishTags, $pager['offset'], $pager['perPage']); $storeTagsPage = array_slice($storeTags, $pager['offset'], $pager['perPage']); $ingredientPage = []; foreach (array_slice($flatIngredients, $pager['offset'], $pager['perPage']) as [$catKey, $row]) { $ingredientPage[$catKey][] = $row; } @endphp @php /* .qm-d2: the dashboard density standard (app.css §35) */ @endphp
@php /* * Six flat tabs hid the point: the last two do not sit BESIDE the first four, they classify them. * The settings shell says so — one labelled group for the lists, one for the dimensions that group * them — and it has room for a seventh entry, which a horizontal tab row does not. * * These stay LINKS rather than the settings shell's usual data-panel buttons: each tab is a * server-rendered page with its own ?page slice, so switching one has to be a request. Only the * layout is borrowed, not the client-side panel switching. */ $counts = [ 'ingredients' => $totalIngredients, 'cuisines' => count($cuisines), 'food-types' => count($dishTags), 'store-types' => count($storeTags), 'categories' => count($categoryRows ?? []), 'allergens' => count($allergenRows ?? []), 'attributes' => count($attributeRows ?? []), 'groups' => count($groupRows ?? []), ]; $navIcons = [ 'ingredients' => 'fa-carrot', 'cuisines' => 'fa-globe', 'food-types' => 'fa-utensils', 'store-types' => 'fa-store', 'categories' => 'fa-layer-group', 'groups' => 'fa-tags', 'allergens' => 'fa-exclamation-triangle', 'attributes' => 'fa-star', ]; @endphp
@php /* =============================================== INGREDIENTS */ @endphp @if ($activeTab === 'ingredients')

{{ t_raw('admin.taxonomy.heading_ingredients') }}{!! $catFilter !== '' ? ' — ' . e($ingredientCats[$catFilter] ?? $catFilter) : '' !!}

@php /* One input for the whole tab even though the list is drawn as several tables: the categories are headings over ONE library, and a row only ever moves inside its own heading because that is where its ends. */ @endphp @if (count($flatIngredients) > 1)
@csrf
@endif
@php /* The category chips that stood here said the same thing three times over: the Ingredient categories tab now carries every count as a linked "In use", and the list below is already broken by category heading, each with its own count. */ @endphp @if ($totalIngredients > 0) @php /* The heading counts the whole category, while the footer counts the page, so a category split across two pages still says how big it is. */ @endphp @php /* The column header is printed once, above the first category: the groups are sections of one list with identical columns, so repeating the header at every heading reads as several tables rather than one. */ @endphp @php $firstGroup = true; @endphp @foreach ($ingredientPage as $catKey => $rows)

{{ $ingredientCats[$catKey] ?? ucfirst((string) $catKey) }}

@php $n = count($ingredientsBy[$catKey] ?? []); @endphp{!! $n === 1 ? t('admin.taxonomy.count_item_one', [':n' => $n]) : t('admin.taxonomy.count_item_many', [':n' => $n]) !!}
@if ($firstGroup) @endif @foreach ($rows as $ing) @endforeach
{{ t_raw('common.reorder') }}{{ t_raw('common.name') }}{{ t_raw('common.slug') }}{{ t_raw('admin.taxonomy.col_veg') }}{{ t_raw('common.active') }}{{ t_raw('common.actions') }}
{!! $renderIcon($ing['icon'] ?? null) !!}
{{ $ing['name'] }}
{{ $ing['slug'] }} {!! $ing['is_veg'] ? t('admin.taxonomy.pill_veg') : t('admin.taxonomy.pill_non_veg') !!} {!! $ing['is_active'] ? t('common.yes') : t('common.no') !!}
@csrf
@php $firstGroup = false; @endphp @endforeach @php /* One footer for the library, not one per heading: the categories are sections of a single list and share a single page cursor. The base is the MENU screen, not this file's other three footers: ingredients moved there, and the toolbar carries ?tab= onto the base — so /admin/taxonomy sent page two to a screen that does not know the tab and answered with Cuisines. */ @endphp @partial('table-toolbar', ['pager' => $pager, 'base' => '/admin/menu']) @else

{{ t_raw('admin.taxonomy.empty_ingredients_title') }}

{{ t_raw('admin.taxonomy.empty_ingredients_text') }}

@endif @endif @php /* =============================================== CUISINES */ @endphp @if ($activeTab === 'cuisines')

{{ t_raw('admin.taxonomy.tab_cuisines') }}

@php /* The arrangement posts as one ordered list — the WHOLE set, not the page on screen, so arranging page two cannot disturb page one. 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 (count($cuisines) > 1)
@csrf
@endif
@php $dishById = []; foreach (($cuisineDishTags ?? []) as $dt) { $dishById[(int) $dt['id']] = $dt; } @endphp
@php // No data-sortable here: a column sort is a VIEW of the list, and dropping a row while one // is applied would save that view as the arrangement. @endphp @if ($cuisinesPage) @foreach ($cuisinesPage as $cu) @php $cid = (int) $cu['id']; $myTagIds = $cuisineTagIds[$cid] ?? []; @endphp @php // The same grip the menu arrangers use, at the row's own control height. @endphp @endforeach @else @partial('table-empty', ['colspan' => 7, 'icon' => 'fa-utensils', 'msg' => t_raw('admin.taxonomy.empty_cuisines')]) @endif
{{ t_raw('common.reorder') }}{{ t_raw('common.name') }}{{ t_raw('common.slug') }}{{ t_raw('admin.taxonomy.col_dishes') }}{{ t_raw('nav.restaurants') }}{{ t_raw('common.active') }}{{ t_raw('common.actions') }}
{!! cuisine_icon_html((string) ($cu['slug'] ?? ''), $cu['icon'] ?? null) !!}
{{ $cu['name'] }}
{{ $cu['slug'] }} @foreach ($myTagIds as $tid) @php $dt = $dishById[(int) $tid] ?? null; if (!$dt) continue; @endphp @if (!empty($has3d[(string) $dt['slug']])) {{ $dt['name'] }} @else {!! icon_html($dt['icon'] ?? '', 'fa-utensils', 'qm-img-22') !!} @endif @endforeach @if (!$myTagIds){{ t_raw('admin.taxonomy.none_placeholder') }}@endif {{ (int) ($cu['restaurant_count'] ?? 0) }} {!! (int) ($cu['is_active'] ?? 1) ? t('common.yes') : t('common.no') !!}
@csrf
@partial('table-toolbar', ['pager' => $pager, 'base' => '/admin/taxonomy']) @endif @php /* =============================================== FOOD TYPES (tags) */ @endphp @if ($activeTab === 'food-types')

{{ t_raw('admin.taxonomy.tab_food_types') }}{!! $grpFilter !== '' ? ' — ' . e($tagGroups[$grpFilter] ?? $grpFilter) : '' !!}

@php /* Seeded with THIS tab's whole list, not the page on screen. Food types and store types are both rows of `tags`, so the save appends whichever group was not posted — in its stored order, which keeps that group's own arrangement. */ @endphp @if (count($dishTags) > 1)
@csrf
@endif
@if ($dishTagsPage) @foreach ($dishTagsPage as $tg) @endforeach @else @partial('table-empty', ['colspan' => 6, 'icon' => 'fa-tags', 'msg' => t_raw('admin.taxonomy.empty_food_types')]) @endif
{{ t_raw('common.reorder') }}{{ t_raw('common.name') }}{{ t_raw('common.slug') }}{{ t_raw('common.group') }}{{ t_raw('common.active') }}{{ t_raw('common.actions') }}
{!! $renderIcon($tg['icon'] ?? null, $tg['slug'] ?? '') !!}
{{ $tg['name'] }}
{{ $tg['slug'] }} {{ $tagGroups[$tg['group']] ?? $tg['group'] }} {!! (int) ($tg['is_active'] ?? 1) ? t('common.yes') : t('common.no') !!}
@csrf
@partial('table-toolbar', ['pager' => $pager, 'base' => '/admin/taxonomy']) @endif @php /* =============================================== STORE TYPES (tags) */ @endphp @if ($activeTab === 'store-types')

{{ t_raw('admin.taxonomy.tab_store_types') }}

@if (count($storeTags) > 1)
@csrf
@endif
@if ($storeTagsPage) @foreach ($storeTagsPage as $tg) @endforeach @else @partial('table-empty', ['colspan' => 5, 'icon' => 'fa-store', 'msg' => t_raw('admin.taxonomy.empty_store_types')]) @endif
{{ t_raw('common.reorder') }}{{ t_raw('common.name') }}{{ t_raw('common.slug') }}{{ t_raw('common.active') }}{{ t_raw('common.actions') }}
{!! $renderIcon($tg['icon'] ?? null, $tg['slug'] ?? '') !!}
{{ $tg['name'] }}
{{ $tg['slug'] }} {!! (int) ($tg['is_active'] ?? 1) ? t('common.yes') : t('common.no') !!}
@csrf
@partial('table-toolbar', ['pager' => $pager, 'base' => '/admin/taxonomy']) @endif @php /* ================================= DIMENSIONS (categories + groups) */ @endphp @foreach ($dimensionTabs as $dKey => $d) @php if ($activeTab !== $dKey) { continue; }; @endphp

{{ t_raw($d['heading']) }}

@if (count($d['rows']) > 1)
@csrf
@endif
@if ($d['rows']) @foreach ($d['rows'] as $dr) @php $drId = (int) $dr['id']; @endphp @php /* No icon cell: a dimension is a LABEL, not a thing with artwork, and the same glyph on every row read as a field an operator could change. */ @endphp @php /* The slug is what every classified row stores, so it is shown but never editable — see saveDimension(). */ @endphp @php /* The count is the answer to "why can't I delete this?", so it goes to the rows in question rather than leaving the operator to find them — but only where a list on this screen actually holds them. A group classifies tags and a category classifies ingredients, and each has a tab that filters by it. Allergens and attributes are counted on MENU ITEMS, which no admin list shows, so linking them at the ingredient filter sent the operator to a list that could only ever come back empty; their count is plain text. */ @endphp @php /* A group's tags sit on whichever tab LISTS that group, and store types have their own — Food types is every group except store_type, so the store_type count linked to a list that could only ever come back empty. */ @endphp @php $usageUrl = match ($dKey) { 'groups' => ((string) $dr['slug'] === 'store_type' ? '/admin/taxonomy?tab=store-types&grp=' : '/admin/taxonomy?tab=food-types&grp=') . urlencode((string) $dr['slug']), 'categories' => '/admin/menu?tab=ingredients&cat=' . urlencode((string) $dr['slug']), default => '', }; @endphp @endforeach @else @partial('table-empty', ['colspan' => 6, 'icon' => $d['icon'], 'msg' => t_raw($d['empty'])]) @endif
{{ t_raw('common.reorder') }}{{ t_raw('common.name') }}{{ t_raw('common.slug') }}{{ t_raw('admin.taxonomy.col_in_use') }}{{ t_raw('common.active') }}{{ t_raw('common.actions') }}
{{ $dr['label'] }}{{ $dr['slug'] }}@if ((int) $dr['usage'] > 0 && $usageUrl !== ''){{ (int) $dr['usage'] }}@else{{ (int) $dr['usage'] }}@endif {!! (int) $dr['is_active'] ? t('common.yes') : t('common.no') !!}
@csrf
@endforeach
@php // .qm-card @endphp
@php // .qm-settings-body @endphp
@php // .qm-settings @endphp @php /* ================================= DIMENSION MODAL (categories + groups) */ @endphp @php /* One dialog for both: they differ only in where they post, which the trigger carries. */ @endphp @php /* ====================================================== INGREDIENT MODAL */ @endphp @php /* ====================================================== CUISINE MODAL */ @endphp @php /* ============================================ CUISINE → DISHES MODAL */ @endphp @php /* ============================== FOOD TYPE / STORE TYPE MODAL (one dialog, two tabs) */ @endphp @php /* It never says "tag": that is the table's name. Which noun it uses is decided by the tab that opened it, because a tab is the only way in. */ @endphp @php $isStoreTab = $activeTab === 'store-types'; @endphp
@php /* /.qm-d2 */ @endphp @endsection