feat: implement hierarchical category selection with a tree view in product and category forms.

This commit is contained in:
pbao9
2026-02-13 14:25:37 +07:00
parent 3a10c3e2d7
commit 549e434627
9 changed files with 395 additions and 147 deletions
@@ -27,7 +27,10 @@ class ProductCategoryController extends Controller
public function create()
{
$categories = \App\Models\ProductCategories::all();
$categories = \App\Models\ProductCategories::with('children')
->whereNull('parent_id')
->orderBy('sort_order')
->get();
return view('administrator.pages.product.categories.create', compact('categories'));
}
@@ -43,7 +46,11 @@ class ProductCategoryController extends Controller
if (!$category) {
return redirect()->route('products.group.index')->with('error', 'Category not found');
}
$categories = \App\Models\ProductCategories::where('id', '!=', $id)->get();
$categories = \App\Models\ProductCategories::with('children')
->whereNull('parent_id')
->where('id', '!=', $id)
->orderBy('sort_order')
->get();
return view('administrator.pages.product.categories.edit', compact('category', 'categories'));
}
@@ -11,7 +11,7 @@
['label' => 'Product'],
['label' => 'Product / Services', 'url' => route('products.index')],
['label' => 'Product Categories', 'url' => route('products.group.index')],
['label' => 'Add Product Group', 'active' => true],
['label' => 'Create Category', 'active' => true],
];
@endphp
<x-admin-breadcrumb :items="$breadcrumbItems" />
@@ -39,51 +39,53 @@
@csrf
<div class="row">
<div class="col-md-8">
<div class="card card-primary card-outline rounded-4 shadow-none">
<div class="card card-primary card-outline rounded-4 mb-3 shadow-none">
<div class="card-header">
<h5 class="card-title m-0">Basic Information</h5>
</div>
<div class="card-body">
<div class="form-group mb-3">
<label for="name">Group Name <span class="text-danger">*</span></label>
<input type="text" id="name" name="name" value="{{ old('name') }}" class="form-control" required
<label for="name">Category Name <span class="text-danger">*</span></label>
<input type="text" id="name" name="name" value="{{ old('name') }}"
class="form-control @error('name') is-invalid @enderror" required
placeholder="Enter category name">
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
</div>
<div class="form-group mb-3">
<label for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ old('slug') }}" class="form-control"
placeholder="auto-generated-if-empty">
</div>
<div class="form-group mb-3">
<label for="parent_id">Parent Category</label>
<select name="parent_id" id="parent_id" class="form-control select2">
<option value="">-- Root --</option>
@if(isset($categories))
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('parent_id') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
@endif
</select>
</div>
<div class="card card-outline rounded-4 shadow-none">
<div class="card-header">
<h5 class="card-title m-0">Parent Category</h5>
</div>
<div class="card-body">
@include('administrator.pages.product.categories.partials.category-tree', [
'categories' => $categories,
'selected' => old('parent_id'),
'name' => 'parent_id',
])
@error('parent_id')
<div class="text-danger small mt-1">{{ $message }}</div>
@enderror
</div>
</div>
</div>
<div class="col-md-4">
<div class="card card-secondary card-outline shadow-none rounded-4">
<div class="card card-secondary card-outline rounded-4 shadow-none">
<div class="card-header">
<h5 class="card-title m-0">Classification</h5>
<h5 class="card-title m-0">Settings</h5>
</div>
<div class="card-body">
<div class="form-group mb-3">
<label for="is_active">Status</label>
<label for=" is_active">Status</label>
<select name="is_active" id="is_active" class="form-control select2">
@foreach(\App\Enums\Status::cases() as $status)
<option value="{{ $status->value }}" {{ old('is_active', \App\Enums\Status::ACTIVE->value) == $status->value ? 'selected' : '' }}>
@foreach (\App\Enums\Status::cases() as $status)
<option value="{{ $status->value }}"
{{ old('is_active', \App\Enums\Status::ACTIVE->value) == $status->value ? 'selected' : '' }}>
{{ $status->label() }}
</option>
@endforeach
@@ -92,20 +94,24 @@
<div class="form-group mb-3">
<label for="sort_order">Sort Order</label>
<input type="number" name="sort_order" id="sort_order" class="form-control"
value="{{ old('sort_order', 0) }}">
<input type="number" name="sort_order" id="sort_order"
class="form-control @error('sort_order') is-invalid @enderror"
value="{{ old('sort_order', 0) }}" min="0">
@error('sort_order')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
</div>
</div>
</div>
</div>
<x-fixed-action-bar>
<x-button-crm type="button" variant="outline-info" size="sm" icon="arrow-left"
route="products.group.index">Back</x-button-crm>
<x-button-crm type="submit" variant="outline-primary" size="sm" icon="plus-circle">Create
Category</x-button-crm>
</x-fixed-action-bar>
<x-fixed-action-bar>
<x-button-crm type="button" variant="outline-info" size="sm" icon="arrow-left"
route="products.group.index">Back</x-button-crm>
<x-button-crm type="submit" variant="outline-primary" size="sm" icon="plus-circle">Create
Category</x-button-crm>
</x-fixed-action-bar>
</form>
@endsection
@@ -113,11 +119,7 @@
@foreach (config('assets.datatable.js') as $datatable)
<script src="{{ asset($datatable) }}"></script>
@endforeach
<script>
$(document).ready(function () {
if ($(".select2").length) {
$(".select2").select2();
}
});
</script>
@endpush
@endpush
@push('custom-scripts')
@include('administrator.pages.product.categories.partials.category-tree-scripts')
@endpush
@@ -16,104 +16,111 @@
@endphp
<x-admin-breadcrumb :items="$breadcrumbItems" />
@endsection
@section('tab')
@php
$tabs = [
[
'label' => 'Product',
'route' => 'products.index',
'icon' => 'server',
],
[
'label' => 'Product Group',
'route' => 'products.group.index',
'icon' => 'server',
],
];
@endphp
<x-admin-tab :items="$tabs" />
@endsection
@section('content')
<form action="{{ route('products.group.update', ['id' => $category->id]) }}" method="post">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-8">
<div class="card card-primary card-outline">
<div class="card-header">
<h5 class="card-title m-0">Basic Information</h5>
</div>
<div class="card-body">
<div class="form-group mb-3">
<label for="name">Group Name <span class="text-danger">*</span></label>
<input type="text" id="name" name="name" value="{{ old('name', $category->name) }}"
class="form-control" required placeholder="Enter category name">
</div>
@section('tab')
@php
$tabs = [
[
'label' => 'Product',
'route' => 'products.index',
'icon' => 'server',
],
[
'label' => 'Product Group',
'route' => 'products.group.index',
'icon' => 'server',
],
];
@endphp
<x-admin-tab :items="$tabs" />
@endsection
<div class="form-group mb-3">
<label for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ old('slug', $category->slug) }}"
class="form-control" placeholder="auto-generated-if-empty">
</div>
<div class="form-group mb-3">
<label for="parent_id">Parent Category</label>
<select name="parent_id" id="parent_id" class="form-control select2">
<option value="">-- Root --</option>
@if(isset($categories))
@foreach($categories as $cat)
<option value="{{ $cat->id }}" {{ old('parent_id', $category->parent_id) == $cat->id ? 'selected' : '' }}>{{ $cat->name }}</option>
@endforeach
@endif
</select>
</div>
@section('content')
<form action="{{ route('products.group.update', ['id' => $category->id]) }}" method="post">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-8">
<div class="card card-primary card-outline rounded-4 shadow-none">
<div class="card-header">
<h5 class="card-title m-0">Basic Information</h5>
</div>
<div class="card-body">
<div class="form-group mb-3">
<label for="name">Category Name <span class="text-danger">*</span></label>
<input type="text" id="name" name="name" value="{{ old('name', $category->name) }}"
class="form-control @error('name') is-invalid @enderror" required
placeholder="Enter category name">
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
</div>
<div class="col-md-4">
<div class="card card-secondary card-outline">
<div class="card-header">
<h5 class="card-title m-0">Classification</h5>
</div>
<div class="card-body">
<div class="form-group mb-3">
<label for="is_active">Status</label>
<select name="is_active" id="is_active" class="form-control select2">
@foreach(\App\Enums\Status::cases() as $status)
<option value="{{ $status->value }}" {{ old('is_active', $category->is_active) == $status->value ? 'selected' : '' }}>
{{ $status->label() }}
</option>
@endforeach
</select>
</div>
<div class="form-group mb-3">
<label for="sort_order">Sort Order</label>
<input type="number" name="sort_order" id="sort_order" class="form-control" value="{{ old('sort_order', $category->sort_order) }}">
</div>
</div>
</div>
<div class="card card-outline rounded-4 shadow-none">
<div class="card-header">
<h5 class="card-title m-0">Parent Category</h5>
</div>
<div class="card-body">
@include('administrator.pages.product.categories.partials.category-tree', [
'categories' => $categories,
'selected' => old('parent_id', $category->parent_id),
'name' => 'parent_id',
'exclude' => $category->id,
])
@error('parent_id')
<div class="text-danger small mt-1">{{ $message }}</div>
@enderror
</div>
</div>
</div>
<x-fixed-action-bar>
<x-button-crm type="button" variant="secondary" size="sm" icon="arrow-left" route="products.group.index">Cancel</x-button-crm>
<x-button-crm type="submit" variant="primary" size="sm" icon="save">Update Category</x-button-crm>
</x-fixed-action-bar>
</form>
@endsection
<div class="col-md-4">
<div class="card card-secondary card-outline shadow-none rounded-4">
<div class="card-header">
<h5 class="card-title m-0">Settings</h5>
</div>
<div class="card-body">
<div class="form-group mb-3">
<label for="is_active">Status</label>
<select name="is_active" id="is_active" class="form-control select2">
@foreach(\App\Enums\Status::cases() as $status)
<option value="{{ $status->value }}" {{ old('is_active', $category->is_active) == $status->value ? 'selected' : '' }}>
{{ $status->label() }}
</option>
@endforeach
</select>
</div>
<div class="form-group mb-3">
<label for="sort_order">Sort Order</label>
<input type="number" name="sort_order" id="sort_order"
class="form-control @error('sort_order') is-invalid @enderror"
value="{{ old('sort_order', $category->sort_order) }}" min="0">
@error('sort_order')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
</div>
</div>
</div>
<x-fixed-action-bar>
<x-button-crm type="button" variant="secondary" size="sm" icon="arrow-left"
route="products.group.index">Cancel</x-button-crm>
<x-button-crm type="submit" variant="primary" size="sm" icon="save">Update
Category</x-button-crm>
</x-fixed-action-bar>
</form>
@endsection
@push('plugin-scripts')
@foreach (config('assets.datatable.js') as $datatable)
<script src="{{ asset($datatable) }}"></script>
@endforeach
<script>
$(document).ready(function() {
if ($(".select2").length) {
$(".select2").select2();
}
});
</script>
@endpush
@push('custom-scripts')
@include('administrator.pages.product.categories.partials.category-tree-scripts')
@endpush
@@ -0,0 +1,12 @@
<option value="{{ $category->id }}" {{ $selected == $category->id ? 'selected' : '' }}>
{{ str_repeat('— ', $depth) }}{{ $category->name }}
</option>
@if($category->children->isNotEmpty())
@foreach($category->children as $child)
@include('administrator.pages.product.categories.partials.category-option', [
'category' => $child,
'depth' => $depth + 1,
'selected' => $selected,
])
@endforeach
@endif
@@ -0,0 +1,32 @@
@if(!$exclude || $category->id != $exclude)
<a href="#" class="list-group-item list-group-item-action category-tree-item py-2 px-3 {{ $selected == $category->id ? 'active' : '' }}"
data-id="{{ $category->id }}" data-name="{{ $category->name }}">
<div class="d-flex align-items-center" style="padding-left: {{ $depth * 20 }}px;">
@if($category->children->isNotEmpty())
<span class="category-tree-toggle me-1" data-target="{{ $treeId }}-children-{{ $category->id }}">
<i data-lucide="chevron-right" width="14" height="14" class="tree-arrow"></i>
</span>
@else
<span class="me-1" style="width: 14px; display: inline-block;"></span>
@endif
<i data-lucide="folder" width="14" height="14" class="me-2 text-warning"></i>
<span>{{ $category->name }}</span>
@if(!$category->is_active)
<span class="badge bg-secondary ms-2" style="font-size: 0.65rem;">Inactive</span>
@endif
</div>
</a>
@if($category->children->isNotEmpty())
<div class="category-tree-children" id="{{ $treeId }}-children-{{ $category->id }}" style="display: none;">
@foreach($category->children as $child)
@include('administrator.pages.product.categories.partials.category-tree-item', [
'category' => $child,
'depth' => $depth + 1,
'selected' => $selected,
'exclude' => $exclude,
'treeId' => $treeId,
])
@endforeach
</div>
@endif
@endif
@@ -0,0 +1,131 @@
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.category-tree-picker').forEach(function (picker) {
var treeId = picker.dataset.treeId;
var hiddenInput = document.getElementById(treeId + '-input');
var selectedDisplay = picker.querySelector('.tree-selected-display');
var selectedLabel = picker.querySelector('.tree-selected-label');
var clearBtn = picker.querySelector('.tree-clear-btn');
var searchInput = picker.querySelector('.tree-search-input');
function expandParents(el) {
var parent = el.closest('.category-tree-children');
while (parent) {
parent.style.display = '';
var prev = parent.previousElementSibling;
if (prev) {
var toggleIcon = prev.querySelector('.tree-arrow');
if (toggleIcon) {
toggleIcon.style.transform = 'rotate(90deg)';
}
}
parent = parent.parentElement
? parent.parentElement.closest('.category-tree-children')
: null;
}
}
var activeItem = picker.querySelector('.category-tree-item.active');
if (activeItem && activeItem.dataset.id) {
expandParents(activeItem);
selectedLabel.textContent = activeItem.dataset.name;
selectedDisplay.style.display = '';
}
picker.querySelectorAll('.category-tree-toggle').forEach(function (toggle) {
toggle.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var target = document.getElementById(this.dataset.target);
var arrow = this.querySelector('.tree-arrow');
if (target) {
if (target.style.display === 'none') {
target.style.display = '';
arrow.style.transform = 'rotate(90deg)';
} else {
target.style.display = 'none';
arrow.style.transform = 'rotate(0deg)';
}
}
});
});
picker.querySelectorAll('.category-tree-item').forEach(function (item) {
item.addEventListener('click', function (e) {
e.preventDefault();
picker.querySelectorAll('.category-tree-item').forEach(function (i) {
i.classList.remove('active');
});
this.classList.add('active');
hiddenInput.value = this.dataset.id;
if (this.dataset.id) {
selectedLabel.textContent = this.dataset.name;
selectedDisplay.style.display = '';
} else {
selectedDisplay.style.display = 'none';
}
});
});
clearBtn.addEventListener('click', function () {
hiddenInput.value = '';
selectedDisplay.style.display = 'none';
picker.querySelectorAll('.category-tree-item').forEach(function (i) {
i.classList.remove('active');
});
picker.querySelector('.category-tree-item[data-id=""]').classList.add('active');
});
searchInput.addEventListener('input', function () {
var query = this.value.toLowerCase().trim();
picker.querySelectorAll('.category-tree-children').forEach(function (el) {
el.style.display = query ? '' : 'none';
});
picker.querySelectorAll('.tree-arrow').forEach(function (arrow) {
arrow.style.transform = query ? 'rotate(90deg)' : 'rotate(0deg)';
});
picker.querySelectorAll('.category-tree-item').forEach(function (item) {
if (!item.dataset.id && item.dataset.id !== '0') {
item.style.display = query ? 'none' : '';
return;
}
var name = (item.dataset.name || '').toLowerCase();
item.style.display = (!query || name.includes(query)) ? '' : 'none';
});
});
});
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
<style>
.category-tree-container .list-group-item {
border-left: 0;
border-right: 0;
font-size: 0.875rem;
transition: all 0.15s ease;
}
.category-tree-container .list-group-item:first-child {
border-top: 0;
}
.category-tree-container .list-group-item:hover {
background-color: #f8f9fa;
}
.category-tree-container .list-group-item.active {
background-color: #e8f0fe;
border-color: transparent;
color: #1a73e8;
}
.category-tree-toggle {
cursor: pointer;
display: inline-flex;
align-items: center;
}
.tree-arrow {
transition: transform 0.2s ease;
}
</style>
@@ -0,0 +1,45 @@
@props(['categories', 'selected' => null, 'name' => 'parent_id', 'exclude' => null])
@php $treeId = str_replace(['[', ']', '.'], '-', $name); @endphp
<input type="hidden" name="{{ $name }}" id="{{ $treeId }}-input" value="{{ $selected }}">
<div class="category-tree-picker" data-tree-id="{{ $treeId }}">
<div class="category-tree-search mb-2">
<div class="input-group input-group-sm">
<span class="input-group-text bg-white border-end-0">
<i data-lucide="search" width="14" height="14"></i>
</span>
<input type="text" class="form-control border-start-0 shadow-none tree-search-input"
placeholder="Search categories...">
</div>
</div>
<div class="category-tree-selected-display mb-2 tree-selected-display" style="display: none;">
<span class="badge bg-primary d-inline-flex align-items-center gap-1 px-2 py-1">
<span class="tree-selected-label"></span>
<button type="button" class="btn-close btn-close-white ms-1 tree-clear-btn" style="font-size: 0.5rem;"></button>
</span>
</div>
<div class="category-tree-container border rounded" style="max-height: 300px; overflow-y: auto;">
<div class="list-group list-group-flush">
<a href="#" class="list-group-item list-group-item-action category-tree-item py-2 px-3 {{ !$selected ? 'active' : '' }}"
data-id="" data-name="Root (No Parent)">
<div class="d-flex align-items-center">
<i data-lucide="home" width="14" height="14" class="me-2 text-muted"></i>
<span class="fw-medium">Root (No Parent)</span>
</div>
</a>
@foreach($categories as $category)
@include('administrator.pages.product.categories.partials.category-tree-item', [
'category' => $category,
'depth' => 0,
'selected' => $selected,
'exclude' => $exclude,
'treeId' => $treeId,
])
@endforeach
</div>
</div>
</div>
@@ -204,18 +204,20 @@
<div class="form-group mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label for="product_categories_id">Group</label>
<a href={{ route('products.group.index') }}
<a href="{{ route('products.group.index') }}"
class="text-primary text-decoration-underline">
<i data-lucide="plus" class="w-12px h-12px"></i>
Thêm Group
</a>
</div>
<select class="form-control select2" name="product_categories_id" id="product_categories_id">
<option value="">-- Select Group --</option>
@foreach ($group as $item)
@include('administrator.pages.product.partials.category_options', ['item' => $item, 'level' => 0, 'selected' => old('product_categories_id')])
@endforeach
</select>
@include('administrator.pages.product.categories.partials.category-tree', [
'categories' => $group,
'selected' => old('product_categories_id'),
'name' => 'product_categories_id',
])
@error('product_categories_id')
<div class="text-danger small mt-1">{{ $message }}</div>
@enderror
</div>
{{-- Product Type --}}
<div class="form-group mb-3">
@@ -406,3 +408,7 @@
});
</script>
@endpush
@push('custom-scripts')
@include('administrator.pages.product.categories.partials.category-tree-scripts')
@endpush
@@ -209,12 +209,14 @@ $breadcrumbItems = [
Thêm Group
</a>
</div>
<select class="form-control select2" name="product_categories_id" id="product_categories_id">
<option value="">-- Select Group --</option>
@foreach ($group as $item)
@include('administrator.pages.product.partials.category_options', ['item' => $item, 'level' => 0, 'selected' => old('product_categories_id', $product->product_categories_id)])
@endforeach
</select>
@include('administrator.pages.product.categories.partials.category-tree', [
'categories' => $group,
'selected' => old('product_categories_id', $product->product_categories_id),
'name' => 'product_categories_id',
])
@error('product_categories_id')
<div class="text-danger small mt-1">{{ $message }}</div>
@enderror
</div>
{{-- Operating System (for VPS) --}}
@@ -424,3 +426,7 @@ $breadcrumbItems = [
});
</script>
@endpush
@push('custom-scripts')
@include('administrator.pages.product.categories.partials.category-tree-scripts')
@endpush