- 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 1df697afce
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 All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 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 ## [1.2.1] - 2026-07-24
### Added ### 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). alike, are configurable from Admin > Konfiguracja (see below).
- **Categories & custom fields** — admin-defined categories/subcategories, each with - **Categories & custom fields** — admin-defined categories/subcategories, each with
its own set of custom fields (text/textarea/select/checkbox/date/number) and an 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 - **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 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 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 APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.2.1 VERSION=1.2.2
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en

View File

@@ -316,10 +316,55 @@ class Panel extends Component
return; 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] = ''; $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 public function openSubcategoryEditForm(int $subcategoryId): void
{ {
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId); $sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);

View File

@@ -11,6 +11,6 @@ class Category extends Model
{ {
public function subcategories(): HasMany 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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; 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 class Subcategory extends Model
{ {
public function category(): BelongsTo 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"> <table class="table" style="margin:0;border-top:none">
<thead><tr><th>Podkategoria</th><th></th></tr></thead> <thead><tr><th>Podkategoria</th><th></th></tr></thead>
<tbody> <tbody>
@foreach ($cat->subcategories as $sub) @foreach ($cat->subcategories as $i => $sub)
<tr> <tr>
<td> <td>
<div style="display:flex;flex-direction:column;gap:2px"> <div style="display:flex;flex-direction:column;gap:2px">
@@ -97,7 +97,9 @@ $tabGroups = [
</div> </div>
</td> </td>
<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="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button>
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button> <button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
</div> </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.'); 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']);
});