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; protected $user;
public function __construct(){ public function __construct()
{
$this->user = auth()->user(); $this->user = auth()->user();
} }
@@ -58,8 +59,8 @@
if (is_null($this->user) || !$this->user->can('basic-data.create')) { if (is_null($this->user) || !$this->user->can('basic-data.create')) {
abort(403, 'Sorry! You are not allowed to create branches.'); abort(403, 'Sorry! You are not allowed to create branches.');
} }
$branches = Branch::all();
return view('basicdata::branch.create'); return view('basicdata::branch.create', compact('branches'));
} }
public function edit($id) public function edit($id)
@@ -69,8 +70,9 @@
abort(403, 'Sorry! You are not allowed to update branches.'); abort(403, 'Sorry! You are not allowed to update branches.');
} }
$branch = Branch::find($id); $branch = Branch::findOrFail($id);
return view('basicdata::branch.create', compact('branch')); $branches = Branch::all();
return view('basicdata::branch.create', compact('branch', 'branches'));
} }
public function update(BranchRequest $request, $id) public function update(BranchRequest $request, $id)
@@ -82,6 +84,14 @@
$validate = $request->validated(); $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) { if ($validate) {
try { try {
// Update in database // Update in database
@@ -102,12 +112,25 @@
{ {
// Check if the authenticated user has the required permission to delete branches // Check if the authenticated user has the required permission to delete branches
if (is_null($this->user) || !$this->user->can('basic-data.delete')) { 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 { try {
// Delete from database // Find the branch
$branch = Branch::find($id); $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(); $branch->delete();
return response()->json(['success' => true, 'message' => 'Branch deleted successfully']); 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 // Check if the authenticated user has the required permission to delete branches
if (is_null($this->user) || !$this->user->can('basic-data.delete')) { 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'); $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(); Branch::whereIn('id', $ids)->delete();
return response()->json(['success' => true, 'message' => 'Branches deleted successfully']); 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 // Check if the authenticated user has the required permission to view branches
if (is_null($this->user) || !$this->user->can('basic-data.read')) { 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 // Retrieve data from the database
@@ -172,12 +214,22 @@
// Get the data for the current page // Get the data for the current page
$data = $query->get(); $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 // Calculate the page count
$pageCount = ceil($totalRecords / $request->get('size')); $pageCount = ceil($totalRecords / $request->get('size'));
// Calculate the current page number // Calculate the current page number
$currentPage = 0 + 1; $currentPage = 0 + 1;
// Return the response data as a JSON object // Return the response data as a JSON object
return response()->json([ return response()->json([
'draw' => $request->get('draw'), 'draw' => $request->get('draw'),

View File

@@ -15,6 +15,15 @@
{ {
$rules = [ $rules = [
'name' => 'required|string|max:255', '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', 'status' => 'nullable|boolean',
'authorized_at' => 'nullable|datetime', 'authorized_at' => 'nullable|datetime',
'authorized_status' => 'nullable|string|max:1', 'authorized_status' => 'nullable|string|max:1',

View File

@@ -6,5 +6,21 @@
class Branch extends Base class Branch extends Base
{ {
protected $table = 'branches'; 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 @enderror
</div> </div>
</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"> <div class="flex justify-end">
@if(isset($branch->id)) @if(isset($branch->id))
@can('basic-data.update') @can('basic-data.update')

View File

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

View File

@@ -18,6 +18,7 @@ class BranchControllerTest extends TestCase
protected $user; protected $user;
protected $adminRole; protected $adminRole;
protected $branch; protected $branch;
protected $parentBranch;
protected function setUp(): void protected function setUp(): void
{ {
@@ -64,6 +65,12 @@ class BranchControllerTest extends TestCase
$this->user = User::factory()->create(); $this->user = User::factory()->create();
$this->user->assignRole($this->adminRole); $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 // Create a branch for testing
$this->branch = Branch::create([ $this->branch = Branch::create([
'code' => 'TEST', 'code' => 'TEST',
@@ -137,6 +144,28 @@ class BranchControllerTest extends TestCase
$this->assertDatabaseHas('branches', $branchData); $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] #[Test]
public function user_without_permission_cannot_store_branch() public function user_without_permission_cannot_store_branch()
{ {
@@ -201,6 +230,53 @@ class BranchControllerTest extends TestCase
$this->assertDatabaseHas('branches', $updatedData); $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] #[Test]
public function user_without_permission_cannot_update_branch() public function user_without_permission_cannot_update_branch()
{ {
@@ -277,4 +353,126 @@ class BranchControllerTest extends TestCase
$response->assertStatus(403); $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));
}
} }