diff --git a/app/Http/Controllers/Master/ArtikelKabupatenController.php b/app/Http/Controllers/Master/ArtikelKabupatenController.php
new file mode 100644
index 00000000..c32eea6b
--- /dev/null
+++ b/app/Http/Controllers/Master/ArtikelKabupatenController.php
@@ -0,0 +1,49 @@
+generateListPermission();
+ $clearCache = request('clear_cache', false);
+ if ($clearCache) {
+ (new \App\Services\ArtikelService)->clearCache('artikel', ['filter[id]' => $clearCache]);
+ }
+
+ return view('master.artikel.index')->with($listPermission);
+ }
+
+ /**
+ * Show the form for creating a new resource.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function create(): View
+ {
+ return view('master.artikel.create');
+ }
+
+ /**
+ * Show the form for editing the specified resource.
+ *
+ * @param int $id
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function edit($id): View
+ {
+ return view('master.artikel.edit', compact('id'));
+ }
+}
diff --git a/app/Http/Controllers/Master/ArtikelUploadController.php b/app/Http/Controllers/Master/ArtikelUploadController.php
new file mode 100644
index 00000000..40cdeaad
--- /dev/null
+++ b/app/Http/Controllers/Master/ArtikelUploadController.php
@@ -0,0 +1,51 @@
+pathFolder = 'uploads/artikel';
+ }
+
+ /**
+ * Upload gambar artikel
+ */
+ public function uploadGambar(Request $request)
+ {
+ try {
+ $request->validate([
+ 'file' => 'required|image|mimes:jpg,jpeg,png,gif|max:2048',
+ ]);
+
+ if ($request->file('file')) {
+ $path = $this->uploadFile($request, 'file');
+ $url = Storage::url($path);
+
+ return response()->json([
+ 'success' => true,
+ 'url' => $url,
+ 'path' => $path,
+ ], 200);
+ }
+
+ return response()->json([
+ 'success' => false,
+ 'message' => 'File tidak ditemukan',
+ ], 400);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Upload gagal: ' . $e->getMessage(),
+ ], 500);
+ }
+ }
+}
diff --git a/app/Services/ArtikelService.php b/app/Services/ArtikelService.php
new file mode 100644
index 00000000..5b899677
--- /dev/null
+++ b/app/Services/ArtikelService.php
@@ -0,0 +1,48 @@
+buildCacheKey('artikel', $filters);
+
+ // Ambil dari cache dulu
+ return Cache::remember($cacheKey, $this->cacheTtl, function () use ($filters) {
+ $data = $this->apiRequest('/api/v1/artikel', $filters);
+ if (! $data) {
+ return collect([]);
+ }
+
+ return collect($data)->map(fn ($item) => (object) $item['attributes']);
+ });
+ }
+
+ public function artikelById(int $id)
+ {
+ $cacheKey = "artikel_$id";
+
+ return Cache::remember($cacheKey, $this->cacheTtl, function () use ($id) {
+ $data = $this->apiRequest('/api/v1/artikel/tampil', [
+ 'id' => $id,
+ ]);
+
+ if (is_array($data) && isset($data['data'])) {
+ return (object) $data['data'];
+ }
+
+ return null;
+ });
+ }
+
+ public function clearCache(string $prefix = 'artikel', array $filters = [])
+ {
+ $cacheKey = $this->buildCacheKey($prefix, $filters);
+ Cache::forget($cacheKey);
+ }
+}
diff --git a/catatan_rilis.md b/catatan_rilis.md
index e21b49a3..152fbb9e 100644
--- a/catatan_rilis.md
+++ b/catatan_rilis.md
@@ -2,6 +2,7 @@ Di rilis ini, versi 2512.0.1 berisi penambahan dan perbaikan yang diminta penggu
#### Penambahan Fitur
+1. [#872](https://github.com/OpenSID/OpenKab/issues/872) Penambahan modul artikel OpenSID.
#### Perbaikan BUG
diff --git a/database/migrations/2025_12_09_101721_add_menu_artikel.php b/database/migrations/2025_12_09_101721_add_menu_artikel.php
new file mode 100644
index 00000000..70e8867d
--- /dev/null
+++ b/database/migrations/2025_12_09_101721_add_menu_artikel.php
@@ -0,0 +1,114 @@
+menu;
+
+ // Cari menu "Pengaturan OpenSID"
+ foreach ($menu as $key => $menuItem) {
+ if ($menuItem['text'] === 'Pengaturan OpenSID') {
+ // Cari posisi "Kategori Artikel"
+ $submenu = $menuItem['submenu'];
+ $kategoriIndex = null;
+
+ foreach ($submenu as $index => $sub) {
+ if ($sub['text'] === 'Kategori Artikel') {
+ $kategoriIndex = $index;
+ break;
+ }
+ }
+
+ // Jika Kategori Artikel ditemukan, tambahkan Artikel setelahnya
+ if ($kategoriIndex !== null) {
+ $newSubmenu = [
+ 'icon' => 'far fa-fw fa-circle',
+ 'text' => 'Artikel',
+ 'url' => 'master/artikel',
+ 'permission' => 'master-data-artikel',
+ ];
+
+ // Insert setelah Kategori Artikel
+ array_splice($submenu, $kategoriIndex + 1, 0, [$newSubmenu]);
+ $menu[$key]['submenu'] = $submenu;
+ }
+
+ break;
+ }
+ }
+
+ // Update menu team
+ $team->update(['menu' => $menu]);
+ }
+
+ // Tidak perlu membuat permission baru karena sudah ada 'master-data-artikel'
+ // Permission ini sudah digunakan untuk kategori artikel
+
+ // Sync permission ke role yang sudah ada
+ $roles = Role::all();
+ foreach ($roles as $role) {
+ // Pastikan role punya permission master-data-artikel
+ if (!$role->hasPermissionTo('master-data-artikel-read')) {
+ $permissions = [
+ 'master-data-artikel-read',
+ 'master-data-artikel-write',
+ 'master-data-artikel-edit',
+ 'master-data-artikel-delete',
+ ];
+
+ foreach ($permissions as $permissionName) {
+ $permission = Permission::where('name', $permissionName)->first();
+ if ($permission && !$role->hasPermissionTo($permission)) {
+ $role->givePermissionTo($permission);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ // Remove menu artikel dari semua team
+ $teams = Team::all();
+
+ foreach ($teams as $team) {
+ $menu = $team->menu;
+
+ foreach ($menu as $key => $menuItem) {
+ if ($menuItem['text'] === 'Pengaturan OpenSID') {
+ $submenu = $menuItem['submenu'];
+
+ // Hapus menu Artikel
+ $submenu = array_filter($submenu, function($sub) {
+ return $sub['text'] !== 'Artikel';
+ });
+
+ $menu[$key]['submenu'] = array_values($submenu);
+ break;
+ }
+ }
+
+ $team->update(['menu' => $menu]);
+ }
+ }
+};
diff --git a/resources/views/master/artikel/create.blade.php b/resources/views/master/artikel/create.blade.php
new file mode 100644
index 00000000..48736ff3
--- /dev/null
+++ b/resources/views/master/artikel/create.blade.php
@@ -0,0 +1,331 @@
+@extends('layouts.index')
+
+@section('title', 'Tambah Artikel')
+
+@section('content_header')
+
Tambah Artikel
+@stop
+
+@section('content')
+ @include('partials.breadcrumbs')
+
+@endsection
+
+@section('css')
+
+@stop
+
+@section('js')
+ @include('partials.asset_tinymce')
+
+@endsection
diff --git a/resources/views/master/artikel/edit.blade.php b/resources/views/master/artikel/edit.blade.php
new file mode 100644
index 00000000..63fd6dbd
--- /dev/null
+++ b/resources/views/master/artikel/edit.blade.php
@@ -0,0 +1,392 @@
+@extends('layouts.index')
+
+@section('title', 'Edit Artikel')
+
+@section('content_header')
+ Edit Artikel
+@stop
+
+@section('content')
+ @include('partials.breadcrumbs')
+
+
+
+
+
+
+
+
+
+
+
+@endsection
+
+@section('css')
+
+@stop
+
+@section('js')
+ @include('partials.asset_tinymce')
+
+@endsection
diff --git a/resources/views/master/artikel/index.blade.php b/resources/views/master/artikel/index.blade.php
new file mode 100644
index 00000000..1b94b599
--- /dev/null
+++ b/resources/views/master/artikel/index.blade.php
@@ -0,0 +1,205 @@
+@extends('layouts.index')
+
+@section('title', 'Data Artikel')
+
+@section('content_header')
+ Data Artikel
+@stop
+
+@section('content')
+ @include('partials.breadcrumbs')
+
+
+
+
+
+
+
+
+
+ | No |
+ Aksi |
+ Judul |
+ Kategori |
+ Tanggal Upload |
+ Status |
+
+
+
+
+
+
+
+
+
+@endsection
+
+@section('js')
+
+@endsection
diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php
index a142cbb4..ffa611a1 100644
--- a/routes/breadcrumbs.php
+++ b/routes/breadcrumbs.php
@@ -10,6 +10,7 @@
use App\Models\Setting;
use App\Models\Team;
use App\Models\User;
+use App\Services\ArtikelService;
use App\Services\BantuanService;
use App\Services\KategoriService;
use App\Services\PendudukApiService;
@@ -57,6 +58,18 @@
$trail->push($name);
});
+Breadcrumbs::for('master-data-artikel.index', function (BreadcrumbTrail $trail) {
+ $trail->push('Master Artikel', route('master-data-artikel.index'));
+});
+Breadcrumbs::for('master-data-artikel.create', function (BreadcrumbTrail $trail) {
+ $trail->parent('master-data-artikel.index');
+ $trail->push('Baru');
+});
+Breadcrumbs::for('master-data-artikel.edit', function (BreadcrumbTrail $trail, $id) {
+ $trail->parent('master-data-artikel.index');
+ $trail->push('Edit Artikel');
+});
+
Breadcrumbs::for('users.index', function (BreadcrumbTrail $trail) {
$trail->push('Pengaturan Pengguna', route('users.index'));
});
diff --git a/routes/web.php b/routes/web.php
index 2a7e5d2b..e3b3e83d 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -13,6 +13,8 @@
use App\Http\Controllers\KecamatanController;
use App\Http\Controllers\KeluargaController;
use App\Http\Controllers\LaporanBulananController;
+use App\Http\Controllers\Master\ArtikelKabupatenController;
+use App\Http\Controllers\Master\ArtikelUploadController;
use App\Http\Controllers\Master\BantuanKabupatenController;
use App\Http\Controllers\PendudukController;
use App\Http\Controllers\PlanController;
@@ -253,6 +255,8 @@
Route::prefix('master')
->group(function () {
Route::middleware(['easyauthorize:master-data-bantuan'])->resource('bantuan', BantuanKabupatenController::class)->only(['index', 'create', 'edit']);
+ Route::middleware(['easyauthorize:master-data-artikel'])->resource('artikel', ArtikelKabupatenController::class)->names('master-data-artikel')->only(['index', 'create', 'edit']);
+ Route::post('artikel/upload-gambar', [ArtikelUploadController::class, 'uploadGambar'])->name('artikel.upload_gambar');
Route::controller(AdminWebController::class)->group(function () {
Route::middleware(['permission:master-data-artikel-read'])->get('/kategori/{parrent}', 'kategori_index')->name('master-data-artikel.kategori');
Route::middleware(['permission:master-data-artikel-edit'])->get('/kategori/edit/{id}/{parrent}', 'kategori_edit')->name('master-data-artikel.kategori-edit');
diff --git a/tests/Feature/ArtikelControllerTest.php b/tests/Feature/ArtikelControllerTest.php
new file mode 100644
index 00000000..723ed355
--- /dev/null
+++ b/tests/Feature/ArtikelControllerTest.php
@@ -0,0 +1,136 @@
+get(route('master-data-artikel.index'));
+ $response->assertStatus(200);
+ $response->assertViewIs('master.artikel.index');
+ $response->assertViewHas(['canwrite', 'canedit', 'candelete']);
+ $response->assertSee('Data Artikel');
+ }
+
+ /** @test */
+ public function it_can_access_artikel_create()
+ {
+ $response = $this->get(route('master-data-artikel.create'));
+ $response->assertStatus(200);
+ $response->assertViewIs('master.artikel.create');
+ $response->assertSee('Tambah Artikel');
+ $response->assertSee('Judul Artikel');
+ $response->assertSee('Isi Artikel');
+ $response->assertSee('Kategori');
+ }
+
+ /** @test */
+ public function it_can_access_artikel_edit()
+ {
+ $artikelId = 1; // ID artikel untuk testing
+ $response = $this->get(route('master-data-artikel.edit', ['artikel' => $artikelId]));
+ $response->assertStatus(200);
+ $response->assertViewIs('master.artikel.edit');
+ $response->assertViewHas('id', $artikelId);
+ $response->assertSee('Edit Artikel');
+ }
+
+ /** @test */
+ public function artikel_index_requires_authentication()
+ {
+ auth()->logout();
+ $response = $this->get(route('master-data-artikel.index'));
+ $response->assertRedirect(route('login'));
+ }
+
+ /** @test */
+ public function artikel_create_requires_authentication()
+ {
+ auth()->logout();
+ $response = $this->get(route('master-data-artikel.create'));
+ $response->assertRedirect(route('login'));
+ }
+
+ /** @test */
+ public function artikel_edit_requires_authentication()
+ {
+ auth()->logout();
+ $response = $this->get(route('master-data-artikel.edit', ['artikel' => 1]));
+ $response->assertRedirect(route('login'));
+ }
+
+ /** @test */
+ public function it_can_access_upload_gambar_route()
+ {
+ $response = $this->post(route('artikel.upload_gambar'), [
+ '_token' => csrf_token(),
+ ]);
+
+ // Tanpa file, bisa return validation error (302) atau error lain
+ $this->assertContains($response->status(), [302, 400, 500]);
+ }
+
+ /** @test */
+ public function artikel_index_shows_proper_table_structure()
+ {
+ $response = $this->get(route('master-data-artikel.index'));
+ $response->assertStatus(200);
+
+ // Check table headers
+ $response->assertSee('No');
+ $response->assertSee('Aksi');
+ $response->assertSee('Judul');
+ $response->assertSee('Kategori');
+ $response->assertSee('Tanggal Upload');
+ $response->assertSee('Status');
+ }
+
+ /** @test */
+ public function artikel_create_shows_required_fields()
+ {
+ $response = $this->get(route('master-data-artikel.create'));
+ $response->assertStatus(200);
+
+ // Check required fields marked with asterisk
+ $response->assertSee('Judul Artikel');
+ $response->assertSee('Isi Artikel');
+ $response->assertSee('Kategori');
+ $response->assertSee('text-danger'); // asterisk styling
+ }
+
+ /** @test */
+ public function artikel_create_has_upload_functionality()
+ {
+ $response = $this->get(route('master-data-artikel.create'));
+ $response->assertStatus(200);
+
+ // Check upload elements
+ $response->assertSee('upload_gambar');
+ $response->assertSee('Gambar Utama');
+ }
+
+ /** @test */
+ public function artikel_edit_loads_with_id()
+ {
+ $artikelId = 123;
+ $response = $this->get(route('master-data-artikel.edit', ['artikel' => $artikelId]));
+ $response->assertStatus(200);
+
+ // Check if ID is passed to view
+ $response->assertViewHas('id', $artikelId);
+ $response->assertSee((string) $artikelId);
+ }
+
+ /** @test */
+ public function artikel_routes_are_registered()
+ {
+ // Check if routes exist
+ $this->assertTrue(route('master-data-artikel.index') !== null);
+ $this->assertTrue(route('master-data-artikel.create') !== null);
+ $this->assertTrue(route('artikel.upload_gambar') !== null);
+ }
+}
diff --git a/tests/Feature/ArtikelUploadTest.php b/tests/Feature/ArtikelUploadTest.php
new file mode 100644
index 00000000..4e31502c
--- /dev/null
+++ b/tests/Feature/ArtikelUploadTest.php
@@ -0,0 +1,158 @@
+image('test-artikel.jpg', 800, 600);
+
+ $response = $this->post(route('artikel.upload_gambar'), [
+ 'file' => $file,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'success' => true,
+ ]);
+ $response->assertJsonStructure([
+ 'success',
+ 'url',
+ 'path',
+ ]);
+ }
+
+ /** @test */
+ public function it_rejects_non_image_files()
+ {
+ $file = UploadedFile::fake()->create('document.pdf', 100);
+
+ $response = $this->post(route('artikel.upload_gambar'), [
+ 'file' => $file,
+ ]);
+
+ // Should not return success
+ $this->assertNotEquals(200, $response->status());
+ }
+
+ /** @test */
+ public function it_rejects_files_larger_than_2mb()
+ {
+ $file = UploadedFile::fake()->image('large-image.jpg')->size(3000); // 3MB
+
+ $response = $this->post(route('artikel.upload_gambar'), [
+ 'file' => $file,
+ ]);
+
+ // Should not return success
+ $this->assertNotEquals(200, $response->status());
+ }
+
+ /** @test */
+ public function it_accepts_jpg_format()
+ {
+ $file = UploadedFile::fake()->image('test.jpg');
+
+ $response = $this->post(route('artikel.upload_gambar'), [
+ 'file' => $file,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJsonFragment(['success' => true]);
+ }
+
+ /** @test */
+ public function it_accepts_jpeg_format()
+ {
+ $file = UploadedFile::fake()->image('test.jpeg');
+
+ $response = $this->post(route('artikel.upload_gambar'), [
+ 'file' => $file,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJsonFragment(['success' => true]);
+ }
+
+ /** @test */
+ public function it_accepts_png_format()
+ {
+ $file = UploadedFile::fake()->image('test.png');
+
+ $response = $this->post(route('artikel.upload_gambar'), [
+ 'file' => $file,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJsonFragment(['success' => true]);
+ }
+
+ /** @test */
+ public function it_requires_file_parameter()
+ {
+ $response = $this->post(route('artikel.upload_gambar'), []);
+
+ // Should return error (not success)
+ $this->assertContains($response->status(), [302, 400, 500]);
+ }
+
+ /** @test */
+ public function upload_returns_valid_url()
+ {
+ $file = UploadedFile::fake()->image('artikel-gambar.jpg');
+
+ $response = $this->post(route('artikel.upload_gambar'), [
+ 'file' => $file,
+ ]);
+
+ $response->assertStatus(200);
+ $data = $response->json();
+
+ $this->assertArrayHasKey('url', $data);
+ $this->assertIsString($data['url']);
+ $this->assertNotEmpty($data['url']);
+ }
+
+ /** @test */
+ public function upload_returns_valid_path()
+ {
+ $file = UploadedFile::fake()->image('artikel-gambar.jpg');
+
+ $response = $this->post(route('artikel.upload_gambar'), [
+ 'file' => $file,
+ ]);
+
+ $response->assertStatus(200);
+ $data = $response->json();
+
+ $this->assertArrayHasKey('path', $data);
+ $this->assertIsString($data['path']);
+ $this->assertStringContainsString('uploads/artikel', $data['path']);
+ }
+
+ /** @test */
+ public function upload_requires_authentication()
+ {
+ auth()->logout();
+
+ $file = UploadedFile::fake()->image('test.jpg');
+
+ $response = $this->post(route('artikel.upload_gambar'), [
+ 'file' => $file,
+ ]);
+
+ $response->assertRedirect(route('login'));
+ }
+}
diff --git a/tests/Unit/ArtikelServiceTest.php b/tests/Unit/ArtikelServiceTest.php
new file mode 100644
index 00000000..9a3a2fb5
--- /dev/null
+++ b/tests/Unit/ArtikelServiceTest.php
@@ -0,0 +1,90 @@
+service = new ArtikelService();
+ }
+
+ /** @test */
+ public function it_can_instantiate_artikel_service()
+ {
+ $this->assertInstanceOf(ArtikelService::class, $this->service);
+ }
+
+ /** @test */
+ public function it_builds_cache_key_correctly()
+ {
+ $reflection = new \ReflectionClass($this->service);
+ $method = $reflection->getMethod('buildCacheKey');
+ $method->setAccessible(true);
+
+ $cacheKey = $method->invokeArgs($this->service, ['artikel', ['id' => 1]]);
+
+ $this->assertIsString($cacheKey);
+ $this->assertStringContainsString('artikel', $cacheKey);
+ }
+
+ /** @test */
+ public function clear_cache_removes_cached_data()
+ {
+ $cacheKey = 'test_artikel_cache';
+ Cache::put($cacheKey, 'test_data', 3600);
+
+ $this->assertTrue(Cache::has($cacheKey));
+
+ Cache::forget($cacheKey);
+
+ $this->assertFalse(Cache::has($cacheKey));
+ }
+
+ /** @test */
+ public function it_has_cache_ttl_property()
+ {
+ $reflection = new \ReflectionClass($this->service);
+ $property = $reflection->getProperty('cacheTtl');
+ $property->setAccessible(true);
+
+ $ttl = $property->getValue($this->service);
+
+ $this->assertEquals(3600, $ttl);
+ $this->assertIsInt($ttl);
+ }
+
+ /** @test */
+ public function artikel_method_returns_collection()
+ {
+ // Mock API response untuk testing
+ // Note: Ini membutuhkan mock untuk API request
+ // Untuk test sederhana, kita hanya check method exists
+ $this->assertTrue(method_exists($this->service, 'artikel'));
+ }
+
+ /** @test */
+ public function artikel_by_id_method_exists()
+ {
+ $this->assertTrue(method_exists($this->service, 'artikelById'));
+ }
+
+ /** @test */
+ public function clear_cache_method_exists()
+ {
+ $this->assertTrue(method_exists($this->service, 'clearCache'));
+ }
+
+ /** @test */
+ public function service_extends_base_api_service()
+ {
+ $this->assertInstanceOf(\App\Services\BaseApiService::class, $this->service);
+ }
+}