feat: Implement nested product category display in admin datatable with expandable rows.

This commit is contained in:
pbao9
2026-02-11 17:30:20 +07:00
parent 556b2c02e6
commit 99b61bfb51
5 changed files with 108 additions and 15 deletions
@@ -91,14 +91,17 @@ class ProductCategoryController extends Controller
public function datatable(Request $request)
{
$query = $this->productCategoryRepository->filter(
$request->all(),
[],
array_merge($request->all(), ['parent_id' => null]),
['children'],
[],
['*'],
true
);
return DataTables::eloquent($query)
->addColumn('details_control', function ($d) {
return view('administrator.pages.product.categories.datatable.details_control', ['row' => $d])->render();
})
->addColumn(
'action',
function ($d) use ($request) {
@@ -120,7 +123,7 @@ class ProductCategoryController extends Controller
return '<div class="form-check d-flex align-items-center justify-content-center"><input class="form-check-input dt-checkbox" type="checkbox" value="' . $d->id . '"></div>';
}
)
->rawColumns(['action', 'is_active', 'checkbox'])
->rawColumns(['details_control', 'action', 'is_active', 'checkbox'])
->make(true);
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ class ProductCategories extends Model
public function children()
{
return $this->hasMany(ProductCategories::class, 'parent_id');
return $this->hasMany(ProductCategories::class, 'parent_id')->with('children');
}
public function products()
@@ -44,9 +44,9 @@ class ProductCategoryRepository extends BaseRepository implements ProductCategor
$query->onlyTrashed();
}
// if (!empty($data['vcenter_id'])) {
// $query->where('vcenter_id', $data['vcenter_id']);
// }
if (array_key_exists('parent_id', $data)) {
$query->where('parent_id', $data['parent_id']);
}
if (!empty($withCount)) {
$query->withCount($withCount);
@@ -0,0 +1,3 @@
@if($row->children->count() > 0)
<i data-lucide="plus-circle" class="cursor-pointer text-success mx-auto" style="width:15px; height:15px"></i>
@endif
@@ -41,6 +41,7 @@ $tabs = [
<div class="card-body">
<x-datatable id="productTable" ajax="{{ route('products.group.datatable') }}"
bulkDeleteUrl="{{ route('products.group.bulkDelete') }}" :columns="[
['key' => 'details_control', 'label' => '', 'orderable' => false, 'searchable' => false, 'className' => 'details-control text-center cursor-pointer'],
['key' => 'name', 'label' => 'Group'],
['key' => 'slug', 'label' => 'Slug'],
['key' => 'is_active', 'label' => 'Status'],
@@ -61,17 +62,103 @@ $tabs = [
<script src="{{ asset($datatable) }}"></script>
@endforeach
@endpush
@push('custom-styles')
<style>
/* Lucide icon alignment */
td.details-control {
text-align: center;
vertical-align: middle;
cursor: pointer;
}
</style>
@endpush
@push('custom-scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
// Re-initialize Lucide icons after DataTable draws
$('#productTable').on('draw.dt', function() {
setTimeout(function() {
$('#productTable').on('draw.dt', function () {
setTimeout(function () {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Disable double click on rows to prevent unwanted editing
$('#productTable tbody').off('dblclick');
}, 100);
});
// Child Row (Details Control) Handler
var tableId = '#productTable';
$(tableId + ' tbody').on('click', 'td.details-control', function () {
var tr = $(this).closest('tr');
var table = $(tableId).DataTable();
var row = table.row(tr);
var cell = $(this);
if (row.child.isShown()) {
// This row is already open - close it
row.child.hide();
tr.removeClass('shown');
// Reset icon to Plus
cell.html('<i data-lucide="plus-circle" class="cursor-pointer text-success mx-auto" style="width:15px; height:15px"></i>');
} else {
// Open this row
if (row.data().children && row.data().children.length > 0) {
row.child(format(row.data())).show();
tr.addClass('shown');
// Change icon to Minus
cell.html('<i data-lucide="minus-circle" class="cursor-pointer text-danger mx-auto" style="width:15px; height:15px"></i>');
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
}
// Re-init icons for the newly added markup (plus/minus)
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
function format(d) {
// `d` is the original data object for the row
if (!d.children || d.children.length === 0) {
return '';
}
var childHtml = '<div class="p-3 bg-light rounded">';
childHtml += '<h6 class="mb-2 text-primary font-weight-bold">Sub Categories</h6>';
childHtml += '<table class="table table-sm table-bordered bg-white mb-0">';
childHtml += '<thead class="thead-light"><tr><th>Group</th><th>Slug</th><th>Status</th><th>Action</th></tr></thead>';
childHtml += '<tbody>';
d.children.forEach(function (child) {
// Re-use logic for status badge if possible, or simple text for now
var statusBadge = child.is_active ?
'<span class="badge bg-success">Active</span>' :
'<span class="badge bg-secondary">Inactive</span>';
// Build Action Buttons (Edit/Delete)
var editUrl = '{{ route("products.group.edit", ":id") }}'.replace(':id', child.id);
var actionButtons = `
<div class="d-flex gap-2">
<a href="${editUrl}" class="btn btn-icon btn-sm btn-outline-primary" title="Edit">
<i data-lucide="edit-2" width="14" height="14"></i>
</a>
</div>
`;
childHtml += '<tr>' +
'<td>' + child.name + '</td>' +
'<td>' + child.slug + '</td>' +
'<td>' + statusBadge + '</td>' +
'<td>' + actionButtons + '</td>' +
'</tr>';
});
childHtml += '</tbody></table></div>';
return childHtml;
}
});
</script>
@endpush
@@ -82,32 +169,32 @@ $tabs = [
.modal-backdrop {
z-index: 9998 !important;
}
.modal {
z-index: 9999 !important;
}
/* Override datatable wrapper overflow to prevent clipping modals */
.data-table-wrapper {
overflow: visible !important;
overflow-x: auto !important;
}
/* Reduce z-index of sticky elements to be below modal */
.data-table-wrapper .table thead th {
z-index: 3 !important;
}
.table thead th:last-child,
.table tbody td:last-child {
z-index: 2 !important;
}
.table thead th:first-child,
.table tbody td:first-child {
z-index: 2 !important;
}
.table thead th:nth-child(2) {
z-index: 4 !important;
}