@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
| {{ 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') !!} |
{{ t_raw('admin.taxonomy.empty_ingredients_text') }}
| {{ 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']]))
|
{{ (int) ($cu['restaurant_count'] ?? 0) }} | {!! (int) ($cu['is_active'] ?? 1) ? t('common.yes') : t('common.no') !!} |
| {{ 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') !!} |
| {{ 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') !!} |
| {{ 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') }} |
|---|---|---|---|---|---|
| @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 | {{ $dr['label'] }} | @php /* The slug is what every classified row stores, so it is shown but never editable — see saveDimension(). */ @endphp{{ $dr['slug'] }} |
@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
@if ((int) $dr['usage'] > 0 && $usageUrl !== ''){{ (int) $dr['usage'] }}@else{{ (int) $dr['usage'] }}@endif | {!! (int) $dr['is_active'] ? t('common.yes') : t('common.no') !!} |