- Subcategories can now be reordered within their category — up/down arrow
  buttons next to "Edytuj" in Admin > Kategorie. The order set there is used
  everywhere a subcategory list is shown (pickers, admin listings, etc.).
This commit is contained in:
2026-07-24 13:58:18 +02:00
parent 313e01ad24
commit 993a5ef44d
9 changed files with 149 additions and 7 deletions

View File

@@ -3,6 +3,16 @@
All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.2.2] - 2026-07-24
### Added
- Subcategories can now be reordered within their category — up/down arrow
buttons next to "Edytuj" in Admin > Kategorie, right beside each
subcategory's edit/delete buttons. The order set there is used everywhere a
subcategory list is shown (subcategory pickers, admin listings, etc.), not
just the admin panel itself.
## [1.2.1] - 2026-07-24
### Added

View File

@@ -46,7 +46,9 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
alike, are configurable from Admin > Konfiguracja (see below).
- **Categories & custom fields** — admin-defined categories/subcategories, each with
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
optional default priority.
optional default priority. Subcategories within a category can be reordered with
up/down arrows in Admin > Kategorie; the order set there is what clients/operators
see everywhere a subcategory picker is shown.
- **Teams** — subcategories auto-route to a team; operators only see their own
team's queue (plus unrouted tickets and anything assigned to them) unless they're
an admin. Reassigning a ticket to a team, though, is unrestricted — an operator

View File

@@ -5,7 +5,7 @@ APP_DEBUG=false
APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.2.1
VERSION=1.2.2
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

View File

@@ -316,10 +316,55 @@ class Panel extends Component
return;
}
Category::query()->find($categoryId)?->subcategories()->create(['name' => $name]);
$category = Category::query()->find($categoryId);
if (! $category) {
return;
}
$nextPosition = ((int) $category->subcategories()->max('sort_order')) + 1;
$category->subcategories()->create(['name' => $name, 'sort_order' => $nextPosition]);
$this->newSubNames[$categoryId] = '';
}
public function moveSubcategoryUp(int $subcategoryId): void
{
$this->swapSubcategoryOrder($subcategoryId, -1);
}
public function moveSubcategoryDown(int $subcategoryId): void
{
$this->swapSubcategoryOrder($subcategoryId, 1);
}
protected function swapSubcategoryOrder(int $subcategoryId, int $direction): void
{
$sub = Subcategory::query()->find($subcategoryId);
if (! $sub) {
return;
}
$siblings = Subcategory::query()
->where('category_id', $sub->category_id)
->orderBy('sort_order')
->orderBy('id')
->get();
$idx = $siblings->search(fn (Subcategory $s) => $s->id === $sub->id);
$swapIdx = $idx + $direction;
if ($idx === false || $swapIdx < 0 || $swapIdx >= $siblings->count()) {
return;
}
$other = $siblings[$swapIdx];
[$order, $otherOrder] = [$sub->sort_order, $other->sort_order];
$sub->update(['sort_order' => $otherOrder]);
$other->update(['sort_order' => $order]);
}
public function openSubcategoryEditForm(int $subcategoryId): void
{
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);

View File

@@ -11,6 +11,6 @@ class Category extends Model
{
public function subcategories(): HasMany
{
return $this->hasMany(Subcategory::class);
return $this->hasMany(Subcategory::class)->orderBy('sort_order');
}
}

View File

@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['category_id', 'name', 'description', 'default_priority_key'])]
#[Fillable(['category_id', 'name', 'description', 'default_priority_key', 'sort_order'])]
class Subcategory extends Model
{
public function category(): BelongsTo

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('subcategories', function (Blueprint $table) {
$table->unsignedInteger('sort_order')->default(0)->after('default_priority_key');
});
foreach (DB::table('subcategories')->orderBy('category_id')->orderBy('id')->get() as $position => $sub) {
DB::table('subcategories')->where('id', $sub->id)->update(['sort_order' => $position]);
}
}
public function down(): void
{
Schema::table('subcategories', function (Blueprint $table) {
$table->dropColumn('sort_order');
});
}
};

View File

@@ -85,7 +85,7 @@ $tabGroups = [
<table class="table" style="margin:0;border-top:none">
<thead><tr><th>Podkategoria</th><th></th></tr></thead>
<tbody>
@foreach ($cat->subcategories as $sub)
@foreach ($cat->subcategories as $i => $sub)
<tr>
<td>
<div style="display:flex;flex-direction:column;gap:2px">
@@ -97,7 +97,9 @@ $tabGroups = [
</div>
</td>
<td>
<div style="display:flex;gap:6px;justify-content:flex-end">
<div style="display:flex;gap:6px;justify-content:flex-end;align-items:center">
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryUp({{ $sub->id }})" @disabled($i === 0) style="padding:2px;width:24px;height:24px" title="Przesuń wyżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span></button>
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryDown({{ $sub->id }})" @disabled($i === $cat->subcategories->count() - 1) style="padding:2px;width:24px;height:24px" title="Przesuń niżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span></button>
<button class="btn btn-ghost" type="button" wire:click="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button>
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
</div>

View File

@@ -45,3 +45,59 @@ test('admin can add a description to a subcategory via the edit dialog', functio
expect($sub->fresh()->description)->toBe('Problemy z połączeniem VPN.');
});
test('admin can reorder subcategories with up/down arrows', function () {
$admin = adminUser();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
$wifi = $category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
$drukarki = $category->subcategories()->create(['name' => 'Drukarki', 'sort_order' => 2]);
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'categories')
->call('moveSubcategoryUp', $drukarki->id)
->assertOk();
expect($category->fresh()->subcategories->pluck('name')->all())
->toBe(['VPN', 'Drukarki', 'WiFi']);
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'categories')
->call('moveSubcategoryDown', $vpn->id)
->assertOk();
expect($category->fresh()->subcategories->pluck('name')->all())
->toBe(['Drukarki', 'VPN', 'WiFi']);
});
test('moving the top subcategory up or the bottom one down is a no-op', function () {
$admin = adminUser();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
$wifi = $category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'categories')
->call('moveSubcategoryUp', $vpn->id)
->call('moveSubcategoryDown', $wifi->id)
->assertOk();
expect($category->fresh()->subcategories->pluck('name')->all())
->toBe(['VPN', 'WiFi']);
});
test('a new subcategory is appended to the end of the display order', function () {
$admin = adminUser();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
$category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'categories')
->set('newSubNames.'.$category->id, 'Drukarki')
->call('addSubcategory', $category->id)
->assertOk();
expect($category->fresh()->subcategories->pluck('name')->all())
->toBe(['VPN', 'WiFi', 'Drukarki']);
});