feat(basicdata): tambahkan fitur relasi parent-child pada cabang

- Menambahkan kolom `parent_id` pada tabel `branches` dengan migrasi baru.
- Update model `Branch`:
  - Menambahkan relasi `parent()` untuk mendapatkan cabang induk.
  - Menambahkan relasi `children()` untuk mendapatkan anak cabang.
- Update `BranchController`:
  - Menampilkan daftar cabang induk saat membuat atau mengedit cabang.
  - Cek validasi agar cabang tidak bisa menjadi induk dirinya sendiri.
  - Tambahkan larangan hapus cabang jika memiliki anak cabang, baik untuk hapus tunggal maupun multiple.
- Update validation rules pada `BranchRequest` untuk memastikan validitas `parent_id`.
- Update tampilan:
  - Formulir pembuatan/edit cabang: Menampilkan dropdown untuk memilih cabang induk.
  - Daftar cabang: Menampilkan kolom untuk cabang induk.
- Tambahkan test unit:
  - Validasi relasi parent-child pada penyimpanan dan pembaruan cabang.
  - Melarang penghapusan cabang yang memiliki anak.
  - Memastikan perilaku relasi parent-child sesuai ekspektasi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
This commit is contained in:
Daeng Deni Mardaeni
2025-05-18 15:13:52 +07:00
parent 1998d89f84
commit 4a644c3b5d
7 changed files with 342 additions and 10 deletions

View File

@@ -14,7 +14,8 @@
{
protected $user;
public function __construct(){
public function __construct()
{
$this->user = auth()->user();
}
@@ -58,8 +59,8 @@
if (is_null($this->user) || !$this->user->can('basic-data.create')) {
abort(403, 'Sorry! You are not allowed to create branches.');
}
return view('basicdata::branch.create');
$branches = Branch::all();
return view('basicdata::branch.create', compact('branches'));
}
public function edit($id)
@@ -69,8 +70,9 @@
abort(403, 'Sorry! You are not allowed to update branches.');
}
$branch = Branch::find($id);
return view('basicdata::branch.create', compact('branch'));
$branch = Branch::findOrFail($id);
$branches = Branch::all();
return view('basicdata::branch.create', compact('branch', 'branches'));
}
public function update(BranchRequest $request, $id)
@@ -82,6 +84,14 @@
$validate = $request->validated();
// Tambahkan validasi manual untuk memeriksa parent_id
if (isset($validate['parent_id']) && $validate['parent_id'] == $id) {
return redirect()
->back()
->withInput()
->withErrors(['parent_id' => 'Cabang tidak dapat menjadi induk dari dirinya sendiri.']);
}
if ($validate) {
try {
// Update in database
@@ -102,12 +112,25 @@
{
// Check if the authenticated user has the required permission to delete branches
if (is_null($this->user) || !$this->user->can('basic-data.delete')) {
return response()->json(['success' => false, 'message' => 'Sorry! You are not allowed to delete branches.'], 403);
return response()->json([
'success' => false,
'message' => 'Sorry! You are not allowed to delete branches.'
], 403);
}
try {
// Delete from database
// Find the branch
$branch = Branch::find($id);
// Check if the branch has children
if ($branch->children()->exists()) {
return response()->json([
'success' => false,
'message' => 'Cabang dengan anak cabang tidak dapat dihapus.'
], 422);
}
// Delete from database
$branch->delete();
return response()->json(['success' => true, 'message' => 'Branch deleted successfully']);
@@ -120,10 +143,26 @@
{
// Check if the authenticated user has the required permission to delete branches
if (is_null($this->user) || !$this->user->can('basic-data.delete')) {
return response()->json(['success' => false, 'message' => 'Sorry! You are not allowed to delete branches.'], 403);
return response()->json([
'success' => false,
'message' => 'Sorry! You are not allowed to delete branches.'
], 403);
}
$ids = $request->input('ids');
// Check if any of the branches have children
$branchesWithChildren = Branch::whereIn('id', $ids)
->whereHas('children')
->get();
if ($branchesWithChildren->count() > 0) {
return response()->json([
'success' => false,
'message' => 'Beberapa cabang memiliki anak cabang dan tidak dapat dihapus.'
], 422);
}
Branch::whereIn('id', $ids)->delete();
return response()->json(['success' => true, 'message' => 'Branches deleted successfully']);
}
@@ -132,7 +171,10 @@
{
// Check if the authenticated user has the required permission to view branches
if (is_null($this->user) || !$this->user->can('basic-data.read')) {
return response()->json(['success' => false, 'message' => 'Sorry! You are not allowed to view branches.'], 403);
return response()->json([
'success' => false,
'message' => 'Sorry! You are not allowed to view branches.'
], 403);
}
// Retrieve data from the database
@@ -172,12 +214,22 @@
// Get the data for the current page
$data = $query->get();
$data = $data->map(function ($item) {
return [
'id' => $item->id,
'code' => $item->code,
'name' => $item->name,
'parent_id' => $item->parent?->name ?? null,
];
});
// Calculate the page count
$pageCount = ceil($totalRecords / $request->get('size'));
// Calculate the current page number
$currentPage = 0 + 1;
// Return the response data as a JSON object
return response()->json([
'draw' => $request->get('draw'),

View File

@@ -15,6 +15,15 @@
{
$rules = [
'name' => 'required|string|max:255',
'parent_id' => [
'nullable',
'exists:branches,id',
function ($attribute, $value, $fail) {
if ($value == $this->route('branch')) {
$fail('Cabang tidak dapat menjadi induk dari dirinya sendiri.');
}
},
],
'status' => 'nullable|boolean',
'authorized_at' => 'nullable|datetime',
'authorized_status' => 'nullable|string|max:1',

View File

@@ -6,5 +6,21 @@
class Branch extends Base
{
protected $table = 'branches';
protected $fillable = ['code', 'name', 'status', 'authorized_at', 'authorized_status', 'authorized_by'];
protected $fillable = ['code', 'name', 'status', 'authorized_at', 'authorized_status', 'authorized_by', 'parent_id'];
/**
* Get the parent branch of this branch
*/
public function parent()
{
return $this->belongsTo(Branch::class, 'parent_id');
}
/**
* Get the child branches of this branch
*/
public function children()
{
return $this->hasMany(Branch::class, 'parent_id');
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('branches', function (Blueprint $table) {
$table->unsignedBigInteger('parent_id')->nullable()->after('name');
$table->foreign('parent_id')->references('id')->on('branches')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('branches', function (Blueprint $table) {
$table->dropForeign(['parent_id']);
$table->dropColumn('parent_id');
});
}
};

View File

@@ -46,6 +46,26 @@
@enderror
</div>
</div>
<div class="flex items-baseline flex-wrap lg:flex-nowrap gap-2.5">
<label class="form-label max-w-56">
Parent Branch
</label>
<div class="flex flex-wrap items-baseline w-full">
<select class="input @error('parent_id') border-danger bg-danger-light @enderror" name="parent_id">
<option value="">-- Select Parent Branch --</option>
@foreach($branches as $parentBranch)
@if(!isset($branch->id) || $parentBranch->id != $branch->id)
<option value="{{ $parentBranch->id }}" {{ (isset($branch->parent_id) && $branch->parent_id == $parentBranch->id) ? 'selected' : '' }}>
{{ $parentBranch->code }} - {{ $parentBranch->name }}
</option>
@endif
@endforeach
</select>
@error('parent_id')
<em class="alert text-danger text-sm">{{ $message }}</em>
@enderror
</div>
</div>
<div class="flex justify-end">
@if(isset($branch->id))
@can('basic-data.update')

View File

@@ -47,6 +47,10 @@
<span class="sort"> <span class="sort-label"> Cabang </span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[250px]" data-datatable-column="name">
<span class="sort"> <span class="sort-label"> Cabang Induk</span>
<span class="sort-icon"> </span> </span>
</th>
<th class="min-w-[50px] text-center" data-datatable-column="actions">Action</th>
</tr>
</thead>
@@ -168,6 +172,9 @@
name: {
title: 'Cabang',
},
parent_id: {
title: 'Cabang Induk',
},
actions: {
title: 'Status',
render: (item, data) => {

View File

@@ -18,6 +18,7 @@ class BranchControllerTest extends TestCase
protected $user;
protected $adminRole;
protected $branch;
protected $parentBranch;
protected function setUp(): void
{
@@ -64,6 +65,12 @@ class BranchControllerTest extends TestCase
$this->user = User::factory()->create();
$this->user->assignRole($this->adminRole);
// Create a parent branch for testing
$this->parentBranch = Branch::create([
'code' => 'PARENT',
'name' => 'Parent Branch'
]);
// Create a branch for testing
$this->branch = Branch::create([
'code' => 'TEST',
@@ -137,6 +144,28 @@ class BranchControllerTest extends TestCase
$this->assertDatabaseHas('branches', $branchData);
}
#[Test]
public function user_with_permission_can_store_branch_with_parent()
{
$branchData = [
'code' => 'CHILD',
'name' => 'Child Branch',
'parent_id' => $this->parentBranch->id
];
$response = $this->actingAs($this->user)
->post(route('basicdata.branch.store'), $branchData);
$response->assertRedirect(route('basicdata.branch.index'));
$this->assertDatabaseHas('branches', $branchData);
// Verify the relationship
$childBranch = Branch::where('code', 'CHILD')->first();
$this->assertEquals($this->parentBranch->id, $childBranch->parent_id);
$this->assertTrue($childBranch->parent()->exists());
$this->assertEquals($this->parentBranch->id, $childBranch->parent->id);
}
#[Test]
public function user_without_permission_cannot_store_branch()
{
@@ -201,6 +230,53 @@ class BranchControllerTest extends TestCase
$this->assertDatabaseHas('branches', $updatedData);
}
#[Test]
public function user_with_permission_can_update_branch_with_parent()
{
$updatedData = [
'code' => 'UPD',
'name' => 'Updated Branch',
'parent_id' => $this->parentBranch->id
];
$response = $this->actingAs($this->user)
->put(route('basicdata.branch.update', $this->branch->id), $updatedData);
$response->assertRedirect(route('basicdata.branch.index'));
$this->assertDatabaseHas('branches', $updatedData);
// Verify the relationship
$this->branch->refresh();
$this->assertEquals($this->parentBranch->id, $this->branch->parent_id);
$this->assertTrue($this->branch->parent()->exists());
$this->assertEquals($this->parentBranch->id, $this->branch->parent->id);
}
#[Test]
public function user_with_permission_can_remove_parent_from_branch()
{
// First set a parent
$this->branch->update(['parent_id' => $this->parentBranch->id]);
$this->assertEquals($this->parentBranch->id, $this->branch->parent_id);
// Then remove it
$updatedData = [
'code' => 'UPD',
'name' => 'Updated Branch',
'parent_id' => null
];
$response = $this->actingAs($this->user)
->put(route('basicdata.branch.update', $this->branch->id), $updatedData);
$response->assertRedirect(route('basicdata.branch.index'));
// Verify the relationship is removed
$this->branch->refresh();
$this->assertNull($this->branch->parent_id);
$this->assertFalse($this->branch->parent()->exists());
}
#[Test]
public function user_without_permission_cannot_update_branch()
{
@@ -277,4 +353,126 @@ class BranchControllerTest extends TestCase
$response->assertStatus(403);
}
#[Test]
public function branch_cannot_be_its_own_parent()
{
$updatedData = [
'code' => 'SELF',
'name' => 'Self-Referencing Branch',
'parent_id' => $this->branch->id
];
$response = $this->actingAs($this->user)
->from(route('basicdata.branch.edit', $this->branch->id)) // Tambahkan ini untuk mengetahui URL sebelumnya
->put(route('basicdata.branch.update', $this->branch->id), $updatedData);
// Pastikan redirect back dengan kesalahan
$response->assertRedirect();
$response->assertSessionHasErrors('parent_id');
// Periksa bahwa parent_id tidak berubah
$this->branch->refresh();
$this->assertNull($this->branch->parent_id);
}
#[Test]
public function cannot_delete_branch_with_children()
{
// Create a child branch
$childBranch = Branch::create([
'code' => 'CHILD',
'name' => 'Child Branch',
'parent_id' => $this->parentBranch->id
]);
// Verify the relationship is established
$this->assertEquals($this->parentBranch->id, $childBranch->parent_id);
$this->assertTrue($this->parentBranch->children()->exists());
// Try to delete the parent branch
$response = $this->actingAs($this->user)
->delete(route('basicdata.branch.destroy', $this->parentBranch->id));
// Assert that the request fails with the expected message
$response->assertJson([
'success' => false,
'message' => 'Cabang dengan anak cabang tidak dapat dihapus.'
]);
$response->assertStatus(422); // Unprocessable Entity
// Verify the parent branch was not deleted
$this->assertDatabaseHas('branches', [
'id' => $this->parentBranch->id,
'deleted_at' => null
]);
}
#[Test]
public function cannot_delete_multiple_branches_if_any_has_children()
{
// Create a child branch
$childBranch = Branch::create([
'code' => 'CHILD',
'name' => 'Child Branch',
'parent_id' => $this->parentBranch->id
]);
// Create another branch without children
$anotherBranch = Branch::create([
'code' => 'ANOTHER',
'name' => 'Another Branch'
]);
// Try to delete both the parent branch and another branch
$response = $this->actingAs($this->user)
->post(route('basicdata.branch.deleteMultiple'), [
'ids' => [$this->parentBranch->id, $anotherBranch->id]
]);
// Assert that the request fails with the expected message
$response->assertJson([
'success' => false,
'message' => 'Beberapa cabang memiliki anak cabang dan tidak dapat dihapus.'
]);
$response->assertStatus(422); // Unprocessable Entity
// Verify neither branch was deleted
$this->assertDatabaseHas('branches', [
'id' => $this->parentBranch->id,
'deleted_at' => null
]);
$this->assertDatabaseHas('branches', [
'id' => $anotherBranch->id,
'deleted_at' => null
]);
}
#[Test]
public function branch_has_correct_children_relationship()
{
// Create a child branch
$childBranch = Branch::create([
'code' => 'CHILD1',
'name' => 'Child Branch 1',
'parent_id' => $this->parentBranch->id
]);
// Create another child branch
$anotherChildBranch = Branch::create([
'code' => 'CHILD2',
'name' => 'Child Branch 2',
'parent_id' => $this->parentBranch->id
]);
// Refresh parent branch
$this->parentBranch->refresh();
// Assert that the parent has two children
$this->assertEquals(2, $this->parentBranch->children()->count());
// Assert that the children collection contains the two child branches
$this->assertTrue($this->parentBranch->children->contains($childBranch));
$this->assertTrue($this->parentBranch->children->contains($anotherChildBranch));
}
}