From bb3e36bf0285ff1d1b669d71b4230f315811c774 Mon Sep 17 00:00:00 2001 From: Habibie Date: Mon, 31 Aug 2026 13:03:54 +0700 Subject: [PATCH 01/52] Cegah RCE via Theme Hook include_once dan Backtick Operator Bypass pada ThemeHooksValidator --- app/Services/ThemeHooksValidator.php | 234 +++++++++++++++--- .../Security/ThemeUploadSecurityTest.php | 121 ++++++++- tests/Unit/ThemesControllerSecurityTest.php | 158 +++++++++++- 3 files changed, 474 insertions(+), 39 deletions(-) diff --git a/app/Services/ThemeHooksValidator.php b/app/Services/ThemeHooksValidator.php index 73ab3e65d6..04d6e8bd72 100644 --- a/app/Services/ThemeHooksValidator.php +++ b/app/Services/ThemeHooksValidator.php @@ -10,18 +10,44 @@ class ThemeHooksValidator * Dangerous PHP functions that are blocked in hooks.php. */ private const DANGEROUS_FUNCTIONS = [ + // OS command & process execution 'system', 'exec', 'passthru', 'shell_exec', 'popen', 'proc_open', - 'pcntl_exec', 'assert', 'create_function', 'eval', - // BUG FIX: call_user_func / call_user_func_array bypass (Severity: Critical) + 'pcntl_exec', 'pcntl_fork', 'pcntl_alarm', 'pcntl_signal', 'pcntl_wait', + 'pcntl_waitpid', 'pcntl_wexitstatus', 'dl', 'assert', 'create_function', 'eval', + // Dynamic callback & indirect invocation 'call_user_func', 'call_user_func_array', + 'forward_static_call', 'forward_static_call_array', + 'register_shutdown_function', 'register_tick_function', + 'preg_replace_callback', 'preg_replace_callback_array', 'mb_ereg_replace_callback', + 'array_map', 'array_filter', 'array_reduce', 'array_walk', 'array_walk_recursive', + 'unserialize', + // File system & I/O 'file_put_contents', 'file_get_contents', 'fopen', 'fwrite', 'fputs', 'unlink', 'mkdir', 'rmdir', 'rename', 'copy', 'chmod', 'chown', - 'symlink', 'link', 'tmpfile', 'move_uploaded_file', + 'symlink', 'link', 'tmpfile', 'move_uploaded_file', 'touch', + 'readfile', 'file', 'fpassthru', 'highlight_file', 'show_source', + 'fileperms', 'fileowner', 'filegroup', 'chgrp', 'lchown', 'lchgrp', + 'glob', 'scandir', 'opendir', 'readdir', 'dir', + // Environment, configuration & variable manipulation 'extract', 'parse_str', 'putenv', 'ini_set', 'ini_alter', 'header', 'setcookie', 'define', 'defined', - 'base64_decode', 'urldecode', + // Encoding / Obfuscation helpers + 'base64_decode', 'urldecode', 'hex2bin', + 'gzinflate', 'gzuncompress', 'gzdecode', 'str_rot13', 'convert_uudecode', + ]; + + /** + * Dangerous PHP language construct tokens that are blocked in hooks.php. + */ + private const DANGEROUS_LANGUAGE_TOKENS = [ + T_EVAL, + T_INCLUDE, + T_INCLUDE_ONCE, + T_REQUIRE, + T_REQUIRE_ONCE, + T_HALT_COMPILER, ]; /** @@ -32,7 +58,7 @@ class ThemeHooksValidator ]; /** - * Scan ZIP archive for files with dangerous PHP extensions. + * Scan ZIP archive for files with dangerous PHP extensions and malicious blade templates. * Must be called BEFORE extractTo(). * * @throws \RuntimeException if dangerous files are detected. @@ -44,8 +70,26 @@ public function scanZipForPhp(\ZipArchive $zip): void for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); - // Skip Blade template files — they are safe Laravel templates + // Skip directories + if (str_ends_with($filename, '/')) { + continue; + } + + // Validate Blade template files if (str_ends_with($filename, '.blade.php')) { + // Blade templates must be inside resources/views/ + if (! str_contains($filename, 'resources/views/')) { + $dangerousFiles[] = "{$filename} (Blade template must reside in resources/views/)"; + continue; + } + + // Scan Blade content for backtick execution operators + $bladeContent = $zip->getFromIndex($i); + if ($bladeContent !== false && str_contains($bladeContent, '`')) { + $dangerousFiles[] = "{$filename} (Backtick execution operator detected in blade template)"; + continue; + } + continue; } @@ -144,12 +188,28 @@ public function loadHooks(string $themePath): void } /** - * Validate hooks.php source code using token_get_all(). + * Validate hooks.php source code using multi-layer token and string analysis. * * Returns ['valid' => bool, 'reason' => string, 'details' => array] */ public function validateSource(string $source, string $themeName): array { + // === LAYER 1: Raw character scan === + // Reject backtick execution operators immediately + if (str_contains($source, '`')) { + return [ + 'valid' => false, + 'reason' => 'Backtick execution operator is not allowed', + 'details' => [ + [ + 'function' => 'backtick_execution', + 'line' => 1, + ], + ], + ]; + } + + // === LAYER 2: Token-based AST analysis === $tokens = @token_get_all($source); if (! is_array($tokens)) { return ['valid' => false, 'reason' => 'hooks.php cannot be parsed', 'details' => []]; @@ -161,70 +221,140 @@ public function validateSource(string $source, string $themeName): array $hasBadCharacter = false; for ($i = 0; $i < $count; $i++) { - if (! is_array($tokens[$i])) { + $token = $tokens[$i]; + + // Handle single-character literal tokens (e.g. '`', '$', '(', etc.) + if (! is_array($token)) { + if ($token === '`') { + $dangerousCalls[] = [ + 'function' => 'backtick_execution', + 'line' => 1, + ]; + } elseif ($token === '$') { + // Check for variable variables like $$var + $nextIdx = $this->getNextMeaningfulTokenIndex($tokens, $i, $count); + if ($nextIdx < $count && is_array($tokens[$nextIdx]) && $tokens[$nextIdx][0] === T_VARIABLE) { + $dangerousCalls[] = [ + 'function' => 'variable_variable:' . $tokens[$nextIdx][1], + 'line' => $tokens[$nextIdx][2], + ]; + } + } continue; } + $tokenId = $token[0]; + $tokenText = $token[1]; + $tokenLine = $token[2]; + + // Double check for backtick inside any token text + if (str_contains($tokenText, '`')) { + $dangerousCalls[] = [ + 'function' => 'backtick_execution', + 'line' => $tokenLine, + ]; + } + // Detect syntax errors via T_BAD_CHARACTER - if ($tokens[$i][0] === T_BAD_CHARACTER) { + if ($tokenId === T_BAD_CHARACTER) { $hasBadCharacter = true; } // Reject inline HTML — hooks.php should be pure PHP - if ($tokens[$i][0] === T_INLINE_HTML) { - $content = trim((string) $tokens[$i][1]); + if ($tokenId === T_INLINE_HTML) { + $content = trim((string) $tokenText); if ($content !== '') { $hasInlineHtml = true; } } - // Detect eval() — T_EVAL is a language construct token, not T_STRING - if ($tokens[$i][0] === T_EVAL) { + // Detect dangerous language constructs (eval, include, require, etc.) + if (in_array($tokenId, self::DANGEROUS_LANGUAGE_TOKENS, true)) { $dangerousCalls[] = [ - 'function' => 'eval', - 'line' => $tokens[$i][2], + 'function' => token_name($tokenId), + 'line' => $tokenLine, ]; continue; } - // Look for function calls: T_STRING followed by '(' - if ($tokens[$i][0] === T_STRING) { - $funcName = strtolower((string) $tokens[$i][1]); + // Detect dynamic class instantiation: new $var() + if ($tokenId === T_NEW) { + $nextIdx = $this->getNextMeaningfulTokenIndex($tokens, $i, $count); + if ($nextIdx < $count && is_array($tokens[$nextIdx]) && $tokens[$nextIdx][0] === T_VARIABLE) { + $dangerousCalls[] = [ + 'function' => 'dynamic_instantiation:' . $tokens[$nextIdx][1], + 'line' => $tokenLine, + ]; + } + continue; + } + + // Detect dynamic method/property calls: $obj->$method or $obj->{$method} + $isNullsafe = defined('T_NULLSAFE_OBJECT_OPERATOR') && $tokenId === T_NULLSAFE_OBJECT_OPERATOR; + if ($tokenId === T_OBJECT_OPERATOR || $isNullsafe) { + $nextIdx = $this->getNextMeaningfulTokenIndex($tokens, $i, $count); + if ($nextIdx < $count) { + if (is_array($tokens[$nextIdx]) && $tokens[$nextIdx][0] === T_VARIABLE) { + $dangerousCalls[] = [ + 'function' => 'dynamic_member_access:' . $tokens[$nextIdx][1], + 'line' => $tokenLine, + ]; + } elseif (! is_array($tokens[$nextIdx]) && $tokens[$nextIdx] === '{') { + $dangerousCalls[] = [ + 'function' => 'dynamic_member_expression', + 'line' => $tokenLine, + ]; + } + } + continue; + } - // Check if the next non-whitespace token is '(' - $nextIdx = $i + 1; - while ($nextIdx < $count && is_array($tokens[$nextIdx]) && $tokens[$nextIdx][0] === T_WHITESPACE) { - $nextIdx++; + // Detect dynamic static calls: Class::$method() or $class::$method() + if ($tokenId === T_PAAMAYIM_NEKUDOTAYIM) { + $nextIdx = $this->getNextMeaningfulTokenIndex($tokens, $i, $count); + if ($nextIdx < $count) { + if (is_array($tokens[$nextIdx]) && $tokens[$nextIdx][0] === T_VARIABLE) { + $dangerousCalls[] = [ + 'function' => 'dynamic_static_call:' . $tokens[$nextIdx][1], + 'line' => $tokenLine, + ]; + } elseif (! is_array($tokens[$nextIdx]) && $tokens[$nextIdx] === '$') { + $dangerousCalls[] = [ + 'function' => 'dynamic_static_expression', + 'line' => $tokenLine, + ]; + } } + continue; + } + + // Look for function calls: T_STRING followed by '(' + if ($tokenId === T_STRING) { + $funcName = strtolower((string) $tokenText); + $nextIdx = $this->getNextMeaningfulTokenIndex($tokens, $i, $count); if ($nextIdx < $count && ! is_array($tokens[$nextIdx]) && $tokens[$nextIdx] === '(') { // Skip if this T_STRING is part of a function/method definition - $prevIdx = $i - 1; - while ($prevIdx >= 0 && is_array($tokens[$prevIdx]) && $tokens[$prevIdx][0] === T_WHITESPACE) { - $prevIdx--; - } + $prevIdx = $this->getPrevMeaningfulTokenIndex($tokens, $i); $isDefinition = ($prevIdx >= 0 && is_array($tokens[$prevIdx]) && $tokens[$prevIdx][0] === T_FUNCTION); if (! $isDefinition && in_array($funcName, self::DANGEROUS_FUNCTIONS, true)) { $dangerousCalls[] = [ 'function' => $funcName, - 'line' => $tokens[$i][2], + 'line' => $tokenLine, ]; } } } - // BUG FIX (High): Detect variable function calls, e.g. $f('id') or $obj->method(). - if ($tokens[$i][0] === T_VARIABLE) { - $nextIdx = $i + 1; - while ($nextIdx < $count && is_array($tokens[$nextIdx]) && $tokens[$nextIdx][0] === T_WHITESPACE) { - $nextIdx++; - } + // Detect variable function calls, e.g. $f('id') + if ($tokenId === T_VARIABLE) { + $nextIdx = $this->getNextMeaningfulTokenIndex($tokens, $i, $count); if ($nextIdx < $count && ! is_array($tokens[$nextIdx]) && $tokens[$nextIdx] === '(') { $dangerousCalls[] = [ - 'function' => 'variable_function_call:' . $tokens[$i][1], - 'line' => $tokens[$i][2], + 'function' => 'variable_function_call:' . $tokenText, + 'line' => $tokenLine, ]; } } @@ -256,4 +386,38 @@ public function validateSource(string $source, string $themeName): array return ['valid' => true, 'reason' => '', 'details' => []]; } + + /** + * Get index of next non-whitespace, non-comment token. + */ + private function getNextMeaningfulTokenIndex(array $tokens, int $currentIndex, int $count): int + { + $next = $currentIndex + 1; + while ($next < $count) { + if (is_array($tokens[$next]) && in_array($tokens[$next][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + $next++; + continue; + } + break; + } + + return $next; + } + + /** + * Get index of previous non-whitespace, non-comment token. + */ + private function getPrevMeaningfulTokenIndex(array $tokens, int $currentIndex): int + { + $prev = $currentIndex - 1; + while ($prev >= 0) { + if (is_array($tokens[$prev]) && in_array($tokens[$prev][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + $prev--; + continue; + } + break; + } + + return $prev; + } } diff --git a/tests/Feature/Security/ThemeUploadSecurityTest.php b/tests/Feature/Security/ThemeUploadSecurityTest.php index 5d3248ad65..184b13895e 100644 --- a/tests/Feature/Security/ThemeUploadSecurityTest.php +++ b/tests/Feature/Security/ThemeUploadSecurityTest.php @@ -2,12 +2,11 @@ namespace Tests\Feature\Security; -use App\Models\User; use App\Models\Themes; +use App\Models\User; use Database\Seeders\RoleSpatieSeeder; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; -use Tests\TestCase; use ZipArchive; describe('Theme Upload Security (RCE Prevention)', function () { @@ -218,6 +217,90 @@ @unlink($hooksFile); }); + // ── Issue #57 FIX: Backtick Execution Operator Bypass ── + + test('hooks_with_backtick_execution_is_rejected', function () { + $hooksDir = base_path('themes/default'); + if (! is_dir($hooksDir)) { + mkdir($hooksDir, 0755, true); + } + + $hooksFile = $hooksDir . '/hooks.php'; + $proofFile = $hooksDir . '/rce_proof.txt'; + @unlink($proofFile); + + // PoC payload from Issue #57 + file_put_contents($hooksFile, ' ' . $proofFile . ' 2>&1`;'); + + $theme = Themes::firstOrCreate( + ['name' => 'default'], + [ + 'vendor' => 'opendk', + 'version' => '1.0', + 'description' => 'Default', + 'path' => $hooksDir, + 'system' => true, + 'active' => 0, + ] + ); + + $r = $this->actingAs($this->superAdmin) + ->get(route('setting.themes.activate', $theme)); + + $r->assertStatus(302); + $r->assertSessionHas('error'); + + // Verify that command was NOT executed and proof file was NOT created + expect(file_exists($proofFile))->toBeFalse(); + + @unlink($hooksFile); + @unlink($proofFile); + }); + + test('hooks_with_include_construct_is_rejected', function () { + $hooksDir = base_path('themes/default'); + if (! is_dir($hooksDir)) { + mkdir($hooksDir, 0755, true); + } + + $hooksFile = $hooksDir . '/hooks.php'; + file_put_contents($hooksFile, ' 'default'], + [ + 'vendor' => 'opendk', + 'version' => '1.0', + 'description' => 'Default', + 'path' => $hooksDir, + 'system' => true, + 'active' => 0, + ] + ); + + $r = $this->actingAs($this->superAdmin) + ->get(route('setting.themes.activate', $theme)); + + $r->assertStatus(302); + $r->assertSessionHas('error'); + + @unlink($hooksFile); + }); + + test('upload_with_backtick_in_blade_template_is_rejected', function () { + $file = makeZipWithBacktickBlade(); + $r = $this->actingAs($this->superAdmin) + ->post(route('setting.themes.upload'), ['file' => $file]); + $r->assertJson(['status' => 'error']); + }); + + test('upload_with_misplaced_blade_template_is_rejected', function () { + $file = makeZipWithMisplacedBlade(); + $r = $this->actingAs($this->superAdmin) + ->post(route('setting.themes.upload'), ['file' => $file]); + $r->assertJson(['status' => 'error']); + }); + // ── Helpers ── function makeZipWithPhp(): UploadedFile @@ -233,6 +316,38 @@ function makeZipWithPhp(): UploadedFile return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); } + /** + * Helper — creates a ZIP with backtick in a Blade template. + */ + function makeZipWithBacktickBlade(): UploadedFile + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid() . '.zip'; + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE); + $zip->addFromString('evil-theme/composer.json', '{"name":"evil"}'); + $zip->addFromString('evil-theme/theme.json', '{"api_version":"v1"}'); + $zip->addFromString('evil-theme/resources/views/layouts/evil.blade.php', '
`id`
'); + $zip->close(); + + return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); + } + + /** + * Helper — creates a ZIP with a Blade template outside resources/views/. + */ + function makeZipWithMisplacedBlade(): UploadedFile + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid() . '.zip'; + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE); + $zip->addFromString('evil-theme/composer.json', '{"name":"evil"}'); + $zip->addFromString('evil-theme/theme.json', '{"api_version":"v1"}'); + $zip->addFromString('evil-theme/misplaced.blade.php', '
Bad
'); + $zip->close(); + + return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); + } + /** * BUG 3 FIX: Helper — creates a ZIP with a path traversal entry. */ @@ -250,4 +365,4 @@ function makeZipWithPathTraversal(): UploadedFile return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); } -})->group('security', 'theme-upload'); \ No newline at end of file +})->group('security', 'theme-upload'); diff --git a/tests/Unit/ThemesControllerSecurityTest.php b/tests/Unit/ThemesControllerSecurityTest.php index abde0f5e91..636659948f 100644 --- a/tests/Unit/ThemesControllerSecurityTest.php +++ b/tests/Unit/ThemesControllerSecurityTest.php @@ -109,4 +109,160 @@ test('validateHooksSource rejects inline HTML', function () { $r = $this->validator->validateSource('

x

toBeFalse(); -}); \ No newline at end of file +}); + +// ── Backtick execution operator tests (Issue #57) ── + +test('validateHooksSource rejects simple backtick execution', function () { + $r = $this->validator->validateSource('toBeFalse() + ->and($r['reason'])->toContain('Backtick execution operator'); +}); + +test('validateHooksSource rejects live PoC backtick execution', function () { + $r = $this->validator->validateSource(' themes/default/rce_proof.txt 2>&1`;', 'evil'); + expect($r['valid'])->toBeFalse() + ->and($r['reason'])->toContain('Backtick execution operator'); +}); + +test('validateHooksSource rejects backtick assigned to variable', function () { + $r = $this->validator->validateSource('toBeFalse() + ->and($r['reason'])->toContain('Backtick execution operator'); +}); + +// ── Language constructs tests (include, require) ── + +test('validateHooksSource rejects include construct', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects include_once construct', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects require construct', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects require_once construct', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +// ── Dynamic invocation tests ── + +test('validateHooksSource rejects dynamic class instantiation', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects dynamic method access', function () { + $r = $this->validator->validateSource('$method();', 'evil'); + expect($r['valid'])->toBeFalse(); +}); + +test('validateHooksSource rejects dynamic static call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects variable variables', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects variable function call with comments and whitespace', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +// ── Expanded dangerous functions tests ── + +test('validateHooksSource rejects readfile() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects forward_static_call() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects register_shutdown_function() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects unserialize() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects scandir() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects glob() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +// ── ZIP Blade template scanning tests ── + +test('scanZipForPhp allows valid blade templates inside resources/views/', function () { + $zipPath = sys_get_temp_dir() . '/' . uniqid() . '.zip'; + $z = new ZipArchive(); + $z->open($zipPath, ZipArchive::CREATE); + $z->addFromString('theme.json', '{}'); + $z->addFromString('resources/views/layouts/app.blade.php', '
{{ $page_title }}
'); + $z->close(); + + $z = new ZipArchive(); + $z->open($zipPath); + + $this->validator->scanZipForPhp($z); + + expect(true)->toBeTrue(); + + $z->close(); + @unlink($zipPath); +}); + +test('scanZipForPhp rejects blade template placed outside resources/views/', function () { + $zipPath = sys_get_temp_dir() . '/' . uniqid() . '.zip'; + $z = new ZipArchive(); + $z->open($zipPath, ZipArchive::CREATE); + $z->addFromString('evil.blade.php', '

Bad location

'); + $z->close(); + + $z = new ZipArchive(); + $z->open($zipPath); + + expect(fn () => $this->validator->scanZipForPhp($z)) + ->toThrow(RuntimeException::class); + + $z->close(); + @unlink($zipPath); +}); + +test('scanZipForPhp rejects blade template containing backtick operator', function () { + $zipPath = sys_get_temp_dir() . '/' . uniqid() . '.zip'; + $z = new ZipArchive(); + $z->open($zipPath, ZipArchive::CREATE); + $z->addFromString('resources/views/pages/evil.blade.php', '
`id`
'); + $z->close(); + + $z = new ZipArchive(); + $z->open($zipPath); + + expect(fn () => $this->validator->scanZipForPhp($z)) + ->toThrow(RuntimeException::class); + + $z->close(); + @unlink($zipPath); +}); From 37d69d6ab566255986c39d851a397dc35c586585 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 31 Aug 2026 13:22:33 +0700 Subject: [PATCH 02/52] perbaikan ecryptString --- app/Http/Controllers/LogViewerController.php | 8 +- .../vendor/laravel-log-viewer/log.blade.php | 10 +-- tests/Feature/LogViewerControllerTest.php | 74 +++++++++++++++++++ 3 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 tests/Feature/LogViewerControllerTest.php diff --git a/app/Http/Controllers/LogViewerController.php b/app/Http/Controllers/LogViewerController.php index 0f5e780804..b8ee2ffe90 100644 --- a/app/Http/Controllers/LogViewerController.php +++ b/app/Http/Controllers/LogViewerController.php @@ -81,11 +81,11 @@ public function index() { $folderFiles = []; if ($this->request->input('f')) { - $this->log_viewer->setFolder(Crypt::decrypt($this->request->input('f'))); + $this->log_viewer->setFolder(Crypt::decryptString($this->request->input('f'))); $folderFiles = $this->log_viewer->getFolderFiles(true); } if ($this->request->input('l')) { - $this->log_viewer->setFile(Crypt::decrypt($this->request->input('l'))); + $this->log_viewer->setFile(Crypt::decryptString($this->request->input('l'))); } if ($early_return = $this->earlyReturn()) { @@ -143,7 +143,7 @@ public function index() private function earlyReturn() { if ($this->request->input('f')) { - $this->log_viewer->setFolder(Crypt::decrypt($this->request->input('f'))); + $this->log_viewer->setFolder(Crypt::decryptString($this->request->input('f'))); } if ($this->request->input('dl')) { @@ -178,7 +178,7 @@ private function earlyReturn() */ private function pathFromInput($input_string) { - return $this->log_viewer->pathToLogFile(Crypt::decrypt($this->request->input($input_string))); + return $this->log_viewer->pathToLogFile(Crypt::decryptString($this->request->input($input_string))); } /** diff --git a/resources/views/vendor/laravel-log-viewer/log.blade.php b/resources/views/vendor/laravel-log-viewer/log.blade.php index 22a1b5801f..f53e807aa5 100644 --- a/resources/views/vendor/laravel-log-viewer/log.blade.php +++ b/resources/views/vendor/laravel-log-viewer/log.blade.php @@ -30,7 +30,7 @@
@@ -43,22 +43,22 @@
@if ($current_file) Unduh Bersihkan File Hapus File @if (count($files) > 1) - + Hapus Semua file @endif diff --git a/tests/Feature/LogViewerControllerTest.php b/tests/Feature/LogViewerControllerTest.php new file mode 100644 index 0000000000..542ab9c84a --- /dev/null +++ b/tests/Feature/LogViewerControllerTest.php @@ -0,0 +1,74 @@ +withoutMiddleware([ + Authenticate::class, + RoleMiddleware::class, + PermissionMiddleware::class, + CompleteProfile::class, + GlobalShareMiddleware::class, + ]); +}); + +test('user-supplied log params are decrypted without unserialization', function () { + // Payload dibuat seolah-olah oleh penyerang yang memiliki APP_KEY: + // menyerupai Crypt::decrypt() lama (unserialize=true) terhadap input user. + // decryptString() mengembalikan string mentah tanpa unserialize, sehingga + // object tidak pernah diinstansiasi (tidak ada side effect __wakeup()). + $malicious = Crypt::encryptString(serialize(new ObjectInjectionGadget)); + + $this->get(route('setting.info-sistem').'?f='.urlencode($malicious)); + + expect(ObjectInjectionGadget::$wokenUp)->toBeFalse(); +}); From cba89b40c3c6960bc3d319d72adf3222a602867c Mon Sep 17 00:00:00 2001 From: Habibie Date: Mon, 31 Aug 2026 13:47:31 +0700 Subject: [PATCH 03/52] sesuaikan validator tema --- app/Services/ThemeHooksValidator.php | 6 ++--- .../Security/ThemeUploadSecurityTest.php | 2 +- tests/Unit/ThemesControllerSecurityTest.php | 23 +++++++++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/Services/ThemeHooksValidator.php b/app/Services/ThemeHooksValidator.php index 04d6e8bd72..a2d34356b3 100644 --- a/app/Services/ThemeHooksValidator.php +++ b/app/Services/ThemeHooksValidator.php @@ -83,10 +83,10 @@ public function scanZipForPhp(\ZipArchive $zip): void continue; } - // Scan Blade content for backtick execution operators + // Scan Blade content for PHP/Blade execution blocks containing backtick operator $bladeContent = $zip->getFromIndex($i); - if ($bladeContent !== false && str_contains($bladeContent, '`')) { - $dangerousFiles[] = "{$filename} (Backtick execution operator detected in blade template)"; + if ($bladeContent !== false && preg_match('/(<\?php|@php|\{\{|\{!!)[^>}]*`/', $bladeContent)) { + $dangerousFiles[] = "{$filename} (Backtick execution operator detected in PHP/Blade directive)"; continue; } diff --git a/tests/Feature/Security/ThemeUploadSecurityTest.php b/tests/Feature/Security/ThemeUploadSecurityTest.php index 184b13895e..4a5f9ad9b1 100644 --- a/tests/Feature/Security/ThemeUploadSecurityTest.php +++ b/tests/Feature/Security/ThemeUploadSecurityTest.php @@ -326,7 +326,7 @@ function makeZipWithBacktickBlade(): UploadedFile $zip->open($path, ZipArchive::CREATE); $zip->addFromString('evil-theme/composer.json', '{"name":"evil"}'); $zip->addFromString('evil-theme/theme.json', '{"api_version":"v1"}'); - $zip->addFromString('evil-theme/resources/views/layouts/evil.blade.php', '
`id`
'); + $zip->addFromString('evil-theme/resources/views/layouts/evil.blade.php', '
{{ `id` }}
'); $zip->close(); return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); diff --git a/tests/Unit/ThemesControllerSecurityTest.php b/tests/Unit/ThemesControllerSecurityTest.php index 636659948f..772b109877 100644 --- a/tests/Unit/ThemesControllerSecurityTest.php +++ b/tests/Unit/ThemesControllerSecurityTest.php @@ -250,11 +250,30 @@ @unlink($zipPath); }); -test('scanZipForPhp rejects blade template containing backtick operator', function () { +test('scanZipForPhp allows blade template with JavaScript ES6 template literals', function () { $zipPath = sys_get_temp_dir() . '/' . uniqid() . '.zip'; $z = new ZipArchive(); $z->open($zipPath, ZipArchive::CREATE); - $z->addFromString('resources/views/pages/evil.blade.php', '
`id`
'); + $z->addFromString('theme.json', '{}'); + $z->addFromString('resources/views/widgets/custom.blade.php', ''); + $z->close(); + + $z = new ZipArchive(); + $z->open($zipPath); + + $this->validator->scanZipForPhp($z); + + expect(true)->toBeTrue(); + + $z->close(); + @unlink($zipPath); +}); + +test('scanZipForPhp rejects blade template containing backtick operator in PHP/Blade directive', function () { + $zipPath = sys_get_temp_dir() . '/' . uniqid() . '.zip'; + $z = new ZipArchive(); + $z->open($zipPath, ZipArchive::CREATE); + $z->addFromString('resources/views/pages/evil.blade.php', '
{{ `id` }}
'); $z->close(); $z = new ZipArchive(); From 4525060038a6c3c123be4c6c5826bb7db39169e7 Mon Sep 17 00:00:00 2001 From: Habibie Date: Tue, 1 Sep 2026 17:39:40 +0700 Subject: [PATCH 04/52] perbaiki sesuai reviewer --- app/Services/ThemeHooksValidator.php | 13 ++++++++++- tests/Unit/ThemesControllerSecurityTest.php | 26 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/Services/ThemeHooksValidator.php b/app/Services/ThemeHooksValidator.php index a2d34356b3..2557524ef0 100644 --- a/app/Services/ThemeHooksValidator.php +++ b/app/Services/ThemeHooksValidator.php @@ -223,7 +223,7 @@ public function validateSource(string $source, string $themeName): array for ($i = 0; $i < $count; $i++) { $token = $tokens[$i]; - // Handle single-character literal tokens (e.g. '`', '$', '(', etc.) + // Handle single-character literal tokens (e.g. '`', '$', '(', '}', ']', etc.) if (! is_array($token)) { if ($token === '`') { $dangerousCalls[] = [ @@ -239,6 +239,17 @@ public function validateSource(string $source, string $themeName): array 'line' => $tokens[$nextIdx][2], ]; } + } elseif ($token === '}' || $token === ']') { + // Detect: ${"system"}(), ${'system'}(), ${$a}() → '}(' + // Detect: $_GET["cmd"](), $a['fn']() → '](' + // This catches bypass payloads A, B, C, D from the security review. + $nextIdx = $this->getNextMeaningfulTokenIndex($tokens, $i, $count); + if ($nextIdx < $count && ! is_array($tokens[$nextIdx]) && $tokens[$nextIdx] === '(') { + $dangerousCalls[] = [ + 'function' => $token === '}' ? 'curly_expression_function_call' : 'array_subscript_function_call', + 'line' => 1, + ]; + } } continue; } diff --git a/tests/Unit/ThemesControllerSecurityTest.php b/tests/Unit/ThemesControllerSecurityTest.php index 772b109877..6ec5dc10c1 100644 --- a/tests/Unit/ThemesControllerSecurityTest.php +++ b/tests/Unit/ThemesControllerSecurityTest.php @@ -180,6 +180,32 @@ expect($r['valid'])->toBeFalse(); }); +// ── Exotic bypass tests (Security Review findings A–D) ── + +test('validateHooksSource rejects curly-string function call: ${"system"}()', function () { + $r = $this->validator->validateSource('toBeFalse() + ->and($r['reason'])->toContain('curly_expression_function_call'); +}); + +test('validateHooksSource rejects curly-variable function call: ${$a}()', function () { + $r = $this->validator->validateSource('toBeFalse() + ->and($r['reason'])->toContain('curly_expression_function_call'); +}); + +test('validateHooksSource rejects array-subscript function call: $_GET["cmd"]()', function () { + $r = $this->validator->validateSource('toBeFalse() + ->and($r['reason'])->toContain('array_subscript_function_call'); +}); + +test("validateHooksSource rejects curly-string single-quote function call: \${'system'}()", function () { + $r = $this->validator->validateSource("toBeFalse() + ->and($r['reason'])->toContain('curly_expression_function_call'); +}); + // ── Expanded dangerous functions tests ── test('validateHooksSource rejects readfile() call', function () { From 6026ed1c5d9b41097b54735640b5f008a29bab3d Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 2 Sep 2026 14:36:16 +0700 Subject: [PATCH 05/52] Perbaikan pagination unduhan --- .../views/pages/unduhan/form-dokumen.blade.php | 13 +++++++++---- .../views/pages/unduhan/prosedur.blade.php | 13 +++++++++---- .../views/pages/unduhan/regulasi.blade.php | 13 +++++++++---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/themes/opendk/default/resources/views/pages/unduhan/form-dokumen.blade.php b/themes/opendk/default/resources/views/pages/unduhan/form-dokumen.blade.php index 07b1e21d86..50efbcad01 100644 --- a/themes/opendk/default/resources/views/pages/unduhan/form-dokumen.blade.php +++ b/themes/opendk/default/resources/views/pages/unduhan/form-dokumen.blade.php @@ -30,15 +30,18 @@ $(document).ready(function() { var table = $('#dokumen-table').DataTable({ processing: true, - serverSide: false, + serverSide: true, ajax: { url: '{!! $urlApi !!}/form-dokumen', cache: false, - dataSrc: 'data', + dataSrc: function(json) { + json.recordsTotal = json.meta?.pagination?.total ?? 0; + json.recordsFiltered = json.meta?.pagination?.total ?? 0; + return json.data; + }, data: function(d) { - // Convert DataTables parameters to API format (use safe defaults to avoid NaN) var start = (typeof d.start !== 'undefined' && d.start !== null) ? d.start : 0; - var length = (typeof d.length !== 'undefined' && d.length) ? d.length : (typeof d.pageLength !== 'undefined' ? d.pageLength : 10); + var length = (typeof d.length !== 'undefined' && d.length) ? d.length : 10; var pageNumber = 1; if (length && !isNaN(length)) { pageNumber = Math.floor(start / length) + 1; @@ -52,6 +55,8 @@ }; } }, + pageLength: 10, + lengthMenu: [5, 10, 25, 50, 100], columns: [{ data: null, name: 'aksi', diff --git a/themes/opendk/default/resources/views/pages/unduhan/prosedur.blade.php b/themes/opendk/default/resources/views/pages/unduhan/prosedur.blade.php index b25a3f8713..bfcc3d1778 100644 --- a/themes/opendk/default/resources/views/pages/unduhan/prosedur.blade.php +++ b/themes/opendk/default/resources/views/pages/unduhan/prosedur.blade.php @@ -30,15 +30,18 @@ $(document).ready(function() { var table = $('#prosedur-table').DataTable({ processing: true, - serverSide: false, + serverSide: true, ajax: { url: '{!! $urlApi !!}/prosedur', cache: false, - dataSrc: 'data', + dataSrc: function(json) { + json.recordsTotal = json.meta?.pagination?.total ?? 0; + json.recordsFiltered = json.meta?.pagination?.total ?? 0; + return json.data; + }, data: function(d) { - // Convert DataTables parameters to API format (use safe defaults to avoid NaN) var start = (typeof d.start !== 'undefined' && d.start !== null) ? d.start : 0; - var length = (typeof d.length !== 'undefined' && d.length) ? d.length : (typeof d.pageLength !== 'undefined' ? d.pageLength : 10); + var length = (typeof d.length !== 'undefined' && d.length) ? d.length : 10; var pageNumber = 1; if (length && !isNaN(length)) { pageNumber = Math.floor(start / length) + 1; @@ -50,6 +53,8 @@ }; } }, + pageLength: 10, + lengthMenu: [5, 10, 25, 50, 100], columns: [{ data: 'attributes.judul_prosedur', name: 'judul_prosedur', diff --git a/themes/opendk/default/resources/views/pages/unduhan/regulasi.blade.php b/themes/opendk/default/resources/views/pages/unduhan/regulasi.blade.php index 567698960f..0b2b90993f 100644 --- a/themes/opendk/default/resources/views/pages/unduhan/regulasi.blade.php +++ b/themes/opendk/default/resources/views/pages/unduhan/regulasi.blade.php @@ -30,15 +30,18 @@ $(document).ready(function() { var table = $('#regulasi-table').DataTable({ processing: true, - serverSide: false, + serverSide: true, ajax: { url: '{!! $urlApi !!}/regulasi', cache: false, - dataSrc: 'data', + dataSrc: function(json) { + json.recordsTotal = json.meta?.pagination?.total ?? 0; + json.recordsFiltered = json.meta?.pagination?.total ?? 0; + return json.data; + }, data: function(d) { - // Convert DataTables parameters to API format (use safe defaults to avoid NaN) var start = (typeof d.start !== 'undefined' && d.start !== null) ? d.start : 0; - var length = (typeof d.length !== 'undefined' && d.length) ? d.length : (typeof d.pageLength !== 'undefined' ? d.pageLength : 10); + var length = (typeof d.length !== 'undefined' && d.length) ? d.length : 10; var pageNumber = 1; if (length && !isNaN(length)) { pageNumber = Math.floor(start / length) + 1; @@ -50,6 +53,8 @@ }; } }, + pageLength: 10, + lengthMenu: [5, 10, 25, 50, 100], columns: [{ data: 'attributes.judul', name: 'judul', From 831d05d7a89f7d32ca2eebd64891bdc080a349bc Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Fri, 4 Sep 2026 16:38:43 +0700 Subject: [PATCH 06/52] perbaikan prosedur --- app/Events/ProsedurChanged.php | 55 ++++++++++++++++++ app/Listeners/ClearProsedurCacheListener.php | 57 +++++++++++++++++++ app/Models/Prosedur.php | 11 ++++ .../ClearProsedurCacheListenerTest.php | 55 ++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 app/Events/ProsedurChanged.php create mode 100644 app/Listeners/ClearProsedurCacheListener.php create mode 100644 tests/Unit/Listeners/ClearProsedurCacheListenerTest.php diff --git a/app/Events/ProsedurChanged.php b/app/Events/ProsedurChanged.php new file mode 100644 index 0000000000..3d17be4387 --- /dev/null +++ b/app/Events/ProsedurChanged.php @@ -0,0 +1,55 @@ +prosedur = $prosedur; + } +} diff --git a/app/Listeners/ClearProsedurCacheListener.php b/app/Listeners/ClearProsedurCacheListener.php new file mode 100644 index 0000000000..9ec17c2dfe --- /dev/null +++ b/app/Listeners/ClearProsedurCacheListener.php @@ -0,0 +1,57 @@ +removeCachePrefix($prefix); + } catch (\Exception $e) { + Log::error('Exception occurred while clearing prosedur cache', [ + 'message' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Models/Prosedur.php b/app/Models/Prosedur.php index d1a618d313..7d96b75885 100644 --- a/app/Models/Prosedur.php +++ b/app/Models/Prosedur.php @@ -31,6 +31,7 @@ namespace App\Models; +use App\Events\ProsedurChanged; use App\Traits\HandlesResourceDeletion; use Illuminate\Database\Eloquent\Model; use Cviebrock\EloquentSluggable\Sluggable; @@ -44,6 +45,16 @@ class Prosedur extends Model protected $table = 'das_prosedur'; + /** + * Register model lifecycle hooks. + */ + protected static function booted(): void + { + static::created(fn (Prosedur $prosedur) => ProsedurChanged::dispatch($prosedur)); + static::updated(fn (Prosedur $prosedur) => ProsedurChanged::dispatch($prosedur)); + static::deleted(fn (Prosedur $prosedur) => ProsedurChanged::dispatch($prosedur)); + } + protected $fillable = [ 'judul_prosedur', 'file_prosedur', diff --git a/tests/Unit/Listeners/ClearProsedurCacheListenerTest.php b/tests/Unit/Listeners/ClearProsedurCacheListenerTest.php new file mode 100644 index 0000000000..a3a6e5d692 --- /dev/null +++ b/tests/Unit/Listeners/ClearProsedurCacheListenerTest.php @@ -0,0 +1,55 @@ +shouldReceive('removeCachePrefix') + ->once() + ->with('prosedur:api') + ->andReturnTrue(); + + $this->app->instance(CacheService::class, $cacheService); + + $listener = new ClearProsedurCacheListener(); + $event = new ProsedurChanged(new Prosedur()); + + $listener->handle($event); +}); + +it('reads correct cache prefix from config', function () { + config(['theme-api.prosedur.cache_prefix' => 'custom:prosedur']); + + $cacheService = Mockery::mock(CacheService::class); + $cacheService->shouldReceive('removeCachePrefix') + ->once() + ->with('custom:prosedur') + ->andReturnTrue(); + + $this->app->instance(CacheService::class, $cacheService); + + $listener = new ClearProsedurCacheListener(); + $event = new ProsedurChanged(new Prosedur()); + + $listener->handle($event); +}); + +it('handles exception gracefully', function () { + $cacheService = Mockery::mock(CacheService::class); + $cacheService->shouldReceive('removeCachePrefix') + ->once() + ->andThrow(new \Exception('Cache error')); + + $this->app->instance(CacheService::class, $cacheService); + + \Illuminate\Support\Facades\Log::shouldReceive('error')->once(); + + $listener = new ClearProsedurCacheListener(); + $event = new ProsedurChanged(new Prosedur()); + + // Should not throw exception + $listener->handle($event); +}); From 16dc9910acf2edea2f750d5586377103a0d2e39d Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 7 Sep 2026 10:18:54 +0700 Subject: [PATCH 07/52] Perbaikan entri anggota suplemen --- .../Controllers/Data/SuplemenController.php | 349 +++++++++++----- app/Http/Requests/SuplemenRequest.php | 57 +++ app/Http/Requests/SuplemenTerdataRequest.php | 88 ++++ app/Models/SuplemenTerdata.php | 1 + app/Services/PendudukService.php | 32 +- ..._id_gabungan_to_suplemen_terdata_table.php | 59 +++ .../data/data_suplemen/form_detail.blade.php | 44 +- .../gabungan/create_detail.blade.php | 177 ++++++++ .../gabungan/edit_detail.blade.php | 187 +++++++++ .../data_suplemen/gabungan/show.blade.php | 145 +++++++ .../Data/SuplemenControllerRequestTest.php | 184 ++++++++ tests/Feature/Data/SuplemenGabunganTest.php | 392 ++++++++++++++++++ tests/Unit/Services/PendudukServiceTest.php | 35 +- 13 files changed, 1622 insertions(+), 128 deletions(-) create mode 100644 app/Http/Requests/SuplemenRequest.php create mode 100644 app/Http/Requests/SuplemenTerdataRequest.php create mode 100644 database/migrations/2026_09_07_000001_add_penduduk_id_gabungan_to_suplemen_terdata_table.php create mode 100644 resources/views/data/data_suplemen/gabungan/create_detail.blade.php create mode 100644 resources/views/data/data_suplemen/gabungan/edit_detail.blade.php create mode 100644 resources/views/data/data_suplemen/gabungan/show.blade.php create mode 100644 tests/Feature/Data/SuplemenControllerRequestTest.php create mode 100644 tests/Feature/Data/SuplemenGabunganTest.php diff --git a/app/Http/Controllers/Data/SuplemenController.php b/app/Http/Controllers/Data/SuplemenController.php index 5bb4f28323..78444b8775 100644 --- a/app/Http/Controllers/Data/SuplemenController.php +++ b/app/Http/Controllers/Data/SuplemenController.php @@ -34,24 +34,31 @@ use App\Exports\ExportSuplemen; use App\Exports\ExportSuplemenTerdata; use App\Http\Controllers\Controller; +use App\Http\Requests\SuplemenRequest; +use App\Http\Requests\SuplemenTerdataRequest; use App\Models\DataDesa; use App\Models\Penduduk; use App\Models\Suplemen; use App\Models\SuplemenTerdata; +use App\Services\PendudukService; +use Illuminate\Contracts\View\View; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Maatwebsite\Excel\Facades\Excel; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Yajra\DataTables\DataTables; class SuplemenController extends Controller { /** * Display a listing of the resource. - * - * @return \Illuminate\Http\Response */ - public function index() + public function index(): View { $page_title = 'Data Suplemen'; $page_description = 'Daftar Data Suplemen'; @@ -60,11 +67,11 @@ public function index() } /** - * Return datatable Data Suplemen + * Return datatable Data Suplemen. */ - public function getDataSuplemen() + public function getDataSuplemen(Request $request): ?JsonResponse { - if (request()->ajax()) { + if ($request->ajax()) { return DataTables::of(Suplemen::withCount('terdata')->get()) ->addIndexColumn() ->addColumn('aksi', function ($row) { @@ -88,10 +95,8 @@ public function getDataSuplemen() /** * Show the form for creating a new resource. - * - * @return \Illuminate\Http\Response */ - public function create() + public function create(): View { $page_title = 'Data Suplemen'; $page_description = 'Tambah Data Suplemen'; @@ -101,19 +106,14 @@ public function create() /** * Store a newly created resource in storage. - * - * @return \Illuminate\Http\Response */ - public function store(Request $request) + public function store(SuplemenRequest $request): RedirectResponse { - request()->validate([ - 'nama' => 'required', - ]); - - $request['slug'] = Str::slug($request->nama); + $data = $request->validated(); + $data['slug'] = Str::slug($data['nama']); try { - Suplemen::create($request->all()); + Suplemen::create($data); } catch (\Exception $e) { Log::error('Suplemen creation failed', [ 'error' => $e->getMessage(), @@ -129,11 +129,8 @@ public function store(Request $request) /** * Show the form for editing the specified resource. - * - * @param int $id - * @return \Illuminate\Http\Response */ - public function edit($id) + public function edit(int $id): View { $suplemen = Suplemen::findOrFail($id); $page_title = 'Data Suplemen'; @@ -144,20 +141,14 @@ public function edit($id) /** * Update the specified resource in storage. - * - * @param int $id - * @return \Illuminate\Http\Response */ - public function update(Request $request, $id) + public function update(SuplemenRequest $request, int $id): RedirectResponse { - request()->validate([ - 'nama' => 'required', - ]); - - $request['slug'] = Str::slug($request->nama); + $data = $request->validated(); + $data['slug'] = Str::slug($data['nama']); try { - Suplemen::findOrFail($id)->update($request->all()); + Suplemen::findOrFail($id)->update($data); } catch (\Exception $e) { Log::error('Suplemen update failed', [ 'error' => $e->getMessage(), @@ -173,11 +164,8 @@ public function update(Request $request, $id) /** * Remove the specified resource from storage. - * - * @param int $id - * @return \Illuminate\Http\Response */ - public function destroy($id) + public function destroy(int $id): RedirectResponse { if (SuplemenTerdata::where('suplemen_id', $id)->exists()) { return redirect()->route('data.data-suplemen.index')->with('error', 'Tidak dapat menghapus Data Suplemen yang mempunyai anggota!'); @@ -200,46 +188,74 @@ public function destroy($id) /** * Display the specified resource. - * - * @param int $id - * @return \Illuminate\Http\Response */ - public function show($id) + public function show(int $id): View { $suplemen = Suplemen::findOrFail($id); $sasaran = ['1' => 'Penduduk', '2' => 'Keluarga/KK']; $page_title = 'Anggota Suplemen'; $page_description = 'Anggota Suplemen: ' . ucwords(strtolower($suplemen->nama)); - return view('data.data_suplemen.show', compact('page_title', 'page_description', 'suplemen', 'sasaran')); + $view = $this->isDatabaseGabungan() ? 'data.data_suplemen.gabungan.show' : 'data.data_suplemen.show'; + + return view($view, compact('page_title', 'page_description', 'suplemen', 'sasaran')); } /** - * Return datatable Data Suplemen Terdata + * Return datatable Data Suplemen Terdata. + * + * Saat database gabungan aktif, data dari API database gabungan + * (hasil sinkronisasi desa) digabungkan dengan data anggota lokal. */ - public function getDataSuplemenTerdata($id_terdata) + public function getDataSuplemenTerdata(Request $request, int $id_terdata): ?JsonResponse { - if (request()->ajax()) { - return DataTables::of(SuplemenTerdata::with('penduduk', 'penduduk.desa')->where('suplemen_id', $id_terdata)->get()) + if ($request->ajax()) { + $terdata = SuplemenTerdata::with('penduduk', 'penduduk.desa')->where('suplemen_id', $id_terdata)->get(); + + if ($this->isDatabaseGabungan()) { + $pendudukGabungan = $this->pendudukGabunganBatch( + $terdata->pluck('penduduk_id_gabungan')->filter()->values()->all() + ); + + $terdata = $terdata->map(function (SuplemenTerdata $row) use ($pendudukGabungan) { + if ($row->penduduk_id_gabungan && ! $row->penduduk) { + $penduduk = $pendudukGabungan[(int) $row->penduduk_id_gabungan] ?? null; + + if ($penduduk) { + $row->setRelation('penduduk', $penduduk); + } + } + + return $row; + })->concat($this->suplemenTerdataGabungan($id_terdata, $request->input('desa'))); + } + + return DataTables::of($terdata) ->addIndexColumn() ->addColumn('aksi', function ($row) { - if (!auth()->guest()) { - $data['edit_url'] = route('data.data-suplemen.editdetail', [$row->id, $row->suplemen_id]); - $data['delete_url'] = route('data.data-suplemen.destroydetail', [$row->id, $row->suplemen_id]); + if (! $row instanceof SuplemenTerdata) { + return null; + } + + if (auth()->guest()) { + return null; } + $data['edit_url'] = auth()->user()->can('access.data.data_suplemen.edit') ? route('data.data-suplemen.editdetail', [$row->id, $row->suplemen_id]) : null; + $data['delete_url'] = auth()->user()->can('access.data.data_suplemen.delete') ? route('data.data-suplemen.destroydetail', [$row->id, $row->suplemen_id]) : null; + return view('forms.aksi', $data); }) ->editColumn('penduduk.sex', function ($row) { $sex = ['1' => 'Laki-laki', '2' => 'Perempuan']; - return $sex[$row->penduduk->sex]; + return $sex[$row->penduduk->sex] ?? '-'; }) ->make(); } } - public function getPenduduk($desa, $suplemen) + public function getPenduduk(string $desa, int $suplemen): JsonResponse { $penduduk = []; @@ -258,83 +274,97 @@ public function getPenduduk($desa, $suplemen) /** * Show the form for creating a new resource. - * - * @return \Illuminate\Http\Response */ - public function createDetail($id_suplemen) + public function createDetail(int $id_suplemen): View { $suplemen = Suplemen::findOrFail($id_suplemen); $sasaran = ['1' => 'Penduduk', '2' => 'Keluarga/KK']; $page_title = 'Anggota Suplemen'; $page_description = 'Tambah Anggota Suplemen'; - $desa = DataDesa::all(); $anggota = null; + $isDatabaseGabungan = $this->isDatabaseGabungan(); - if ($suplemen->sasaran == 1) { - $data = Penduduk::get(); + if ($isDatabaseGabungan) { + $data = collect(); + $desa = collect(); + $view = 'data.data_suplemen.gabungan.create_detail'; } else { - $data = Penduduk::where('kk_level', 1)->get(); + $desa = DataDesa::all(); + $view = 'data.data_suplemen.create_detail'; + + if ($suplemen->sasaran == 1) { + $data = Penduduk::get(); + } else { + $data = Penduduk::where('kk_level', 1)->get(); + } } - return view('data.data_suplemen.create_detail', compact('page_title', 'page_description', 'suplemen', 'sasaran', 'data', 'desa', 'anggota')); + return view($view, compact('page_title', 'page_description', 'suplemen', 'sasaran', 'data', 'desa', 'anggota', 'isDatabaseGabungan')); } /** * Store a newly created resource in storage. - * - * @return \Illuminate\Http\Response */ - public function storeDetail(Request $request) + public function storeDetail(SuplemenTerdataRequest $request): RedirectResponse { - request()->validate( - ['penduduk_id' => 'required'], - ['penduduk_id.required' => 'isian warga atau penduduk wajib diisi'] - ); + $data = $this->normalizePendudukSumber($request->validated()); try { - SuplemenTerdata::create($request->all()); + SuplemenTerdata::create($data); } catch (\Exception $e) { Log::error('Suplemen Terdata creation failed', [ 'error' => $e->getMessage(), 'user_id' => auth()->id(), - 'suplemen_id' => $request['suplemen_id'], + 'suplemen_id' => $request->input('suplemen_id'), ]); return back()->withInput()->with('error', 'Anggota Suplemen gagal ditambah!'); } - return redirect()->route('data.data-suplemen.show', $request['suplemen_id'])->with('success', 'Anggota Suplemen berhasil ditambah!'); + return redirect()->route('data.data-suplemen.show', $request->input('suplemen_id'))->with('success', 'Anggota Suplemen berhasil ditambah!'); } /** * Show the form for editing the specified resource. - * - * @param int $id - * @return \Illuminate\Http\Response */ - public function editDetail($id, $id_suplemen) + public function editDetail(int $id, int $id_suplemen): View { $suplemen = Suplemen::findOrFail($id_suplemen); $sasaran = ['1' => 'Penduduk', '2' => 'Keluarga/KK']; $page_title = 'Anggota Suplemen'; $page_description = 'Ubah Anggota Suplemen'; - $anggota = SuplemenTerdata::with('penduduk', 'penduduk.desa')->where('id', $id)->first(); - $data = Penduduk::where('id', $anggota->penduduk_id)->get(); + $anggota = SuplemenTerdata::with('penduduk', 'penduduk.desa')->where('id', $id)->firstOrFail(); + $isDatabaseGabungan = $this->isDatabaseGabungan(); + + if ($isDatabaseGabungan) { + $penduduk = null; + + if ($anggota->penduduk_id_gabungan) { + $penduduk = $this->pendudukGabunganBatch([$anggota->penduduk_id_gabungan])[(int) $anggota->penduduk_id_gabungan] ?? null; + } + + $selectedDesaId = $penduduk ? $penduduk->desa->desa_id : null; + + return view('data.data_suplemen.gabungan.edit_detail', compact('page_title', 'page_description', 'suplemen', 'sasaran', 'anggota', 'isDatabaseGabungan', 'penduduk', 'selectedDesaId')); + } + $desa = DataDesa::all(); + $data = $anggota->penduduk_id ? Penduduk::where('id', $anggota->penduduk_id)->get() : collect(); + $selectedDesaId = optional($anggota->penduduk)->desa->desa_id; + $selectedPendudukId = optional($anggota->penduduk)->id; - return view('data.data_suplemen.edit_detail', compact('page_title', 'page_description', 'suplemen', 'sasaran', 'data', 'desa', 'anggota')); + return view('data.data_suplemen.edit_detail', compact('page_title', 'page_description', 'suplemen', 'sasaran', 'data', 'desa', 'anggota', 'isDatabaseGabungan', 'selectedDesaId', 'selectedPendudukId')); } /** * Update the specified resource in storage. - * - * @param int $id - * @return \Illuminate\Http\Response */ - public function updateDetail(Request $request, $id) + public function updateDetail(SuplemenTerdataRequest $request, int $id): RedirectResponse { + $data = $this->normalizePendudukSumber($request->validated()); + try { - SuplemenTerdata::findOrFail($id)->update($request->all()); + SuplemenTerdata::findOrFail($id)->update($data); } catch (\Exception $e) { Log::error('Suplemen Terdata update failed', [ 'error' => $e->getMessage(), @@ -345,16 +375,13 @@ public function updateDetail(Request $request, $id) return back()->withInput()->with('error', 'Anggota Suplemen gagal diubah!'); } - return redirect()->route('data.data-suplemen.show', $request['suplemen_id'])->with('success', 'Anggota Suplemen berhasil diubah!'); + return redirect()->route('data.data-suplemen.show', $request->input('suplemen_id'))->with('success', 'Anggota Suplemen berhasil diubah!'); } /** * Remove the specified resource from storage. - * - * @param int $id - * @return \Illuminate\Http\Response */ - public function destroyDetail($id, $id_suplemen) + public function destroyDetail(int $id, int $id_suplemen): RedirectResponse { try { SuplemenTerdata::findOrFail($id)->delete(); @@ -374,11 +401,8 @@ public function destroyDetail($id, $id_suplemen) /** * Export Excel data suplemen. - * - * @param Request $request - * @return \Symfony\Component\HttpFoundation\BinaryFileResponse */ - public function exportExcel(Request $request) + public function exportExcel(Request $request): BinaryFileResponse { $filters = $request->only(['nama', 'sasaran']); $timestamp = date('Y-m-d-H-i-s'); @@ -389,12 +413,8 @@ public function exportExcel(Request $request) /** * Export Excel data suplemen terdata. - * - * @param Request $request - * @param int $id Suplemen ID - * @return \Symfony\Component\HttpFoundation\BinaryFileResponse */ - public function exportTerdataExcel(Request $request, $id) + public function exportTerdataExcel(Request $request, int $id): BinaryFileResponse { $suplemen = Suplemen::findOrFail($id); $filters = $request->only(['desa', 'nama_penduduk']); @@ -403,4 +423,145 @@ public function exportTerdataExcel(Request $request, $id) return Excel::download(new ExportSuplemenTerdata($id, $filters), $filename); } + + /** + * Normalisasi sumber penduduk untuk anggota suplemen. + * + * Anggota hanya boleh berisi salah satu sumber: penduduk lokal (penduduk_id) + * atau penduduk dari database gabungan (penduduk_id_gabungan), tidak keduanya. + */ + private function normalizePendudukSumber(array $validated): array + { + if (! empty($validated['penduduk_id_gabungan'])) { + $validated['penduduk_id'] = null; + } else { + $validated['penduduk_id_gabungan'] = null; + } + + return $validated; + } + + /** + * Resolusi beberapa penduduk dari database gabungan sekaligus (batch) + * menggunakan filter id_penduduk berupa array, agar tidak melakukan + * request berulang per anggota. + * + * @param array $ids + * + * @return array + */ + private function pendudukGabunganBatch(array $ids): array + { + if (empty($ids)) { + return []; + } + + try { + $penduduk = []; + + foreach ((new PendudukService())->pendudukGabunganByIds($ids) as $item) { + $penduduk[(int) ($item['id'] ?? 0)] = $this->mapGabunganPenduduk($item); + } + + return $penduduk; + } catch (\Exception $e) { + Log::error('Penduduk batch fetch from gabungan failed', [ + 'error' => $e->getMessage(), + 'ids' => $ids, + ]); + + return []; + } + } + + /** + * Ambil anggota suplemen (terdata) dari API database gabungan. + * + * @return \Illuminate\Support\Collection + */ + private function suplemenTerdataGabungan(int $id, ?string $desa = null): Collection + { + try { + $response = Http::withHeaders($this->gabunganHeaders()) + ->post($this->gabunganUrl("/api/v1/opendk/suplemen-terdata-datatable/{$id}"), [ + 'page[size]' => 5000, + 'page[number]' => 1, + 'filter[kode_kecamatan]' => str_replace('.', '', (string) $this->profil->kecamatan_id), + 'filter[kode_desa]' => ($desa && $desa !== 'Semua') ? $desa : '', + ]); + + if ($response->failed()) { + return collect(); + } + + return collect($response->json('data') ?? [])->map(function (array $item) use ($id) { + $attributes = $item['attributes'] ?? []; + $penduduk = $this->mapGabunganPenduduk($item); + + return (object) [ + 'id' => $item['id'] ?? null, + 'suplemen_id' => $id, + 'penduduk_id' => null, + 'penduduk_id_gabungan' => $item['id'] ?? null, + 'keterangan' => $attributes['keterangan'] ?? null, + 'penduduk' => $penduduk, + ]; + })->values(); + } catch (\Exception $e) { + Log::error('Suplemen terdata fetch from gabungan failed', [ + 'error' => $e->getMessage(), + 'suplemen_id' => $id, + ]); + + return collect(); + } + } + + /** + * Map respons penduduk API database gabungan (JSON:API) ke model Eloquent + * yang digunakan kolom datatable dan form anggota suplemen. + */ + private function mapGabunganPenduduk(array $item): Penduduk + { + $attributes = $item['attributes'] ?? []; + + $penduduk = new Penduduk(); + $penduduk->forceFill([ + 'id' => $item['id'] ?? null, + 'no_kk' => data_get($attributes, 'keluarga.no_kk') ?? $attributes['no_kk'] ?? null, + 'nik' => $attributes['nik'] ?? null, + 'nama' => $attributes['nama'] ?? null, + 'tempat_lahir' => $attributes['tempatlahir'] ?? $attributes['tempat_lahir'] ?? null, + 'tanggal_lahir' => $attributes['tanggallahir'] ?? $attributes['tanggal_lahir'] ?? null, + 'sex' => $attributes['sex'] ?? null, + 'alamat' => $attributes['alamat_sekarang'] ?? $attributes['alamat'] ?? null, + ]); + + $desa = new DataDesa(); + $desa->nama = data_get($attributes, 'config.nama_desa') ?? $attributes['nama_desa'] ?? null; + $desa->desa_id = data_get($attributes, 'config.kode_desa') ?? $attributes['kode_desa'] ?? null; + $penduduk->setRelation('desa', $desa); + + return $penduduk; + } + + /** + * Header otorisasi untuk API database gabungan. + */ + private function gabunganHeaders(): array + { + return [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . ($this->getSettings()['api_key_database_gabungan'] ?? ''), + ]; + } + + /** + * URL endpoint API database gabungan. + */ + private function gabunganUrl(string $path): string + { + return rtrim((string) ($this->getSettings()['api_server_database_gabungan'] ?? ''), '/') . $path; + } } diff --git a/app/Http/Requests/SuplemenRequest.php b/app/Http/Requests/SuplemenRequest.php new file mode 100644 index 0000000000..49daf802fd --- /dev/null +++ b/app/Http/Requests/SuplemenRequest.php @@ -0,0 +1,57 @@ + ['required', 'string', 'max:255'], + 'sasaran' => ['required', 'in:1,2'], + 'keterangan' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/SuplemenTerdataRequest.php b/app/Http/Requests/SuplemenTerdataRequest.php new file mode 100644 index 0000000000..4dcb3814a8 --- /dev/null +++ b/app/Http/Requests/SuplemenTerdataRequest.php @@ -0,0 +1,88 @@ + ['required', 'exists:das_suplemen,id'], + 'penduduk_id' => ['nullable', 'integer'], + 'penduduk_id_gabungan' => ['nullable', 'integer'], + 'keterangan' => ['nullable', 'string'], + ]; + } + + /** + * Pastikan hanya salah satu sumber penduduk yang diisi: + * penduduk lokal (penduduk_id) atau penduduk dari database gabungan (penduduk_id_gabungan). + */ + public function withValidator(\Illuminate\Validation\Validator $validator): void + { + $validator->after(function ($validator) { + $hasLocal = $this->filled('penduduk_id'); + $hasGabungan = $this->filled('penduduk_id_gabungan'); + + if (! $hasLocal && ! $hasGabungan) { + $validator->errors()->add('penduduk_id', 'isian warga atau penduduk wajib diisi'); + } + + if ($hasLocal && $hasGabungan) { + $validator->errors()->add('penduduk_id_gabungan', 'hanya boleh memilih salah satu, penduduk lokal atau penduduk database gabungan'); + } + }); + } + + /** + * Get the error messages for the defined validation rules. + */ + public function messages(): array + { + return [ + 'penduduk_id.required' => 'isian warga atau penduduk wajib diisi', + ]; + } +} diff --git a/app/Models/SuplemenTerdata.php b/app/Models/SuplemenTerdata.php index 4def4e50e0..dae1b869c5 100644 --- a/app/Models/SuplemenTerdata.php +++ b/app/Models/SuplemenTerdata.php @@ -43,6 +43,7 @@ class SuplemenTerdata extends Model protected $fillable = [ 'suplemen_id', 'penduduk_id', + 'penduduk_id_gabungan', 'keterangan', ]; diff --git a/app/Services/PendudukService.php b/app/Services/PendudukService.php index 8372a9959f..885c2a776f 100644 --- a/app/Services/PendudukService.php +++ b/app/Services/PendudukService.php @@ -3,14 +3,13 @@ namespace App\Services; use App\Models\Penduduk; -use App\Models\SettingAplikasi; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class PendudukService extends BaseApiService { /** - * Get Unique Desa + * Get Unique Desa. */ public function jumlahPenduduk(array $filters = []) { @@ -33,7 +32,7 @@ public function jumlahPenduduk(array $filters = []) } /** - * Get Unique Desa + * Get Unique Desa. */ public function desa(array $filters = []) { @@ -59,7 +58,7 @@ public function desa(array $filters = []) } /** - * Export Data Penduduk + * Export Data Penduduk. */ public function exportPenduduk($size, $number, $search) { @@ -110,7 +109,30 @@ public function detailPenduduk($id) } /** - * Export Data Penduduk + * Ambil beberapa data penduduk dari database gabungan sekaligus + * menggunakan filter id_penduduk berupa array. + * + * @param array $ids + * + * @return \Illuminate\Support\Collection + */ + public function pendudukGabunganByIds(array $ids) + { + if (empty($ids)) { + return collect(); + } + + $data = $this->apiRequest('/api/v1/opendk/sync-penduduk-opendk', [ + 'filter[kode_kecamatan]' => str_replace('.', '', config('profil.kecamatan_id')), + 'filter[id_penduduk]' => $ids, + 'page[size]' => count($ids), + ]); + + return collect($data); + } + + /** + * Export Data Penduduk. */ public function cekPendudukNikTanggalLahir($nik, $tgl_lhr = null) { diff --git a/database/migrations/2026_09_07_000001_add_penduduk_id_gabungan_to_suplemen_terdata_table.php b/database/migrations/2026_09_07_000001_add_penduduk_id_gabungan_to_suplemen_terdata_table.php new file mode 100644 index 0000000000..e3317c02a7 --- /dev/null +++ b/database/migrations/2026_09_07_000001_add_penduduk_id_gabungan_to_suplemen_terdata_table.php @@ -0,0 +1,59 @@ +unsignedBigInteger('penduduk_id_gabungan')->nullable()->after('penduduk_id'); + $table->unsignedInteger('penduduk_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::table('das_suplemen_terdata', function (Blueprint $table) { + $table->dropColumn('penduduk_id_gabungan'); + $table->unsignedInteger('penduduk_id')->nullable(false)->change(); + }); + } +} diff --git a/resources/views/data/data_suplemen/form_detail.blade.php b/resources/views/data/data_suplemen/form_detail.blade.php index 22239183d3..ca03e0fd07 100644 --- a/resources/views/data/data_suplemen/form_detail.blade.php +++ b/resources/views/data/data_suplemen/form_detail.blade.php @@ -1,3 +1,9 @@ +@php + $isDatabaseGabungan = $isDatabaseGabungan ?? false; + $pendudukField = $isDatabaseGabungan ? 'penduduk_id_gabungan' : 'penduduk_id'; + $selectedDesaId = $selectedDesaId ?? null; + $selectedPendudukId = $selectedPendudukId ?? null; +@endphp {{ html()->hidden('suplemen_id', $suplemen->id) }} @if ($suplemen->sasaran == 3)
@@ -7,11 +13,7 @@
@@ -20,16 +22,12 @@
- @foreach ($data as $penduduk) - @if ($anggota == null) - - @else - - @endif + @endforeach
@@ -43,11 +41,7 @@
@@ -55,20 +49,16 @@
- @foreach ($data as $penduduk) - @if ($anggota == null) - - @else - - @endif + @endforeach
diff --git a/resources/views/data/data_suplemen/gabungan/create_detail.blade.php b/resources/views/data/data_suplemen/gabungan/create_detail.blade.php new file mode 100644 index 0000000000..82bdf4eb09 --- /dev/null +++ b/resources/views/data/data_suplemen/gabungan/create_detail.blade.php @@ -0,0 +1,177 @@ +@extends('layouts.dashboard_template') + +@section('content') +
+

+ {{ $page_title ?? 'Page Title' }} + {{ $page_description ?? '' }} +

+ +
+
+
+
+
+ + {!! html()->form('POST', route('data.data-suplemen.storedetail'))->id('form-suiplemen-gabungan')->class('form-horizontal form-label-left')->open() !!} + +
+
+ + + + + + + + + + + + + +
Nama: {{ $suplemen->nama }}
Sasaran: {{ $sasaran[$suplemen->sasaran] }}
Keterangan: {{ $suplemen->keterangan }}
+
+
+ Daftar Anggota Suplemen + + @if (count($errors) > 0) +
+ Ups! Ada beberapa masalah dengan masukan Anda.

+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + @include('flash::message') + + {{ html()->hidden('suplemen_id', $suplemen->id) }} + +
+ + +
+ @include('layouts.fragments.select-desa', ['width' => '100%']) +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ {!! html()->textarea('keterangan')->class('textarea')->placeholder('Keterangan')->value(old('keterangan'))->style('width: 100%; height: 200px; font-size: 14px; line-height: 18px; border: 1px solid #dddddd; padding: 10px;') !!} +
+
+
+
+ + {!! html()->form()->close() !!} +
+
+
+
+@endsection + +@include('partials.asset_select2') + +@push('scripts') + +@endpush diff --git a/resources/views/data/data_suplemen/gabungan/edit_detail.blade.php b/resources/views/data/data_suplemen/gabungan/edit_detail.blade.php new file mode 100644 index 0000000000..efa68d96d4 --- /dev/null +++ b/resources/views/data/data_suplemen/gabungan/edit_detail.blade.php @@ -0,0 +1,187 @@ +@extends('layouts.dashboard_template') + +@section('content') +
+

+ {{ $page_title ?? 'Page Title' }} + {{ $page_description ?? '' }} +

+ +
+
+
+
+
+ + @if (count($errors) > 0) +
+ Ups! Ada beberapa masalah dengan masukan Anda.

+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + + {!! html()->form('post', route('data.data-suplemen.updatedetail', $anggota->id))->id('form-suiplemen-gabungan')->class('form-horizontal form-label-left')->open() !!} + +
+
+ + + + + + + + + + + + + +
Nama: {{ $suplemen->nama }}
Sasaran: {{ $sasaran[$suplemen->sasaran] }}
Keterangan: {{ $suplemen->keterangan }}
+
+
+ Daftar Anggota Suplemen + + {{ method_field('PUT') }} + @include('flash::message') + + {{ html()->hidden('suplemen_id', $suplemen->id) }} + +
+ + +
+ @include('layouts.fragments.select-desa', ['selectedOption' => $selectedDesaId]) +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ {!! html()->textarea('keterangan')->class('textarea')->placeholder('Keterangan')->value(old('keterangan', $anggota->keterangan))->style('width: 100%; height: 200px; font-size: 14px; line-height: 18px; border: 1px solid #dddddd; padding: 10px;') !!} +
+
+
+ + + {!! html()->form()->close() !!} +
+
+
+
+@endsection + +@include('partials.asset_select2') + +@push('scripts') + +@endpush diff --git a/resources/views/data/data_suplemen/gabungan/show.blade.php b/resources/views/data/data_suplemen/gabungan/show.blade.php new file mode 100644 index 0000000000..ea81c9f7ad --- /dev/null +++ b/resources/views/data/data_suplemen/gabungan/show.blade.php @@ -0,0 +1,145 @@ +@extends('layouts.dashboard_template') + +@section('content') +
+

+ {{ $page_title ?? 'Page Title' }} + {{ $page_description ?? '' }} +

+ +
+
+ + @include('partials.flash_message') + +
+ +
+
+ + + + + + + + + + + + + +
Nama: {{ $suplemen->nama }}
Sasaran: {{ $sasaran[$suplemen->sasaran] }}
Keterangan: {{ $suplemen->keterangan }}
+
+
+ Daftar Anggota Suplemen + @include('layouts.fragments.list-desa') +
+
+ + + + + + + + + + + + + + + +
Aksi{{ config('setting.sebutan_desa') }}No. KKNIK PendudukNama PendudukTempat LahirTanggal LahirJenis KelaminAlamatKeterangan
+
+
+
+
+@endsection + +@include('partials.asset_select2') +@include('partials.asset_datatables') + +@push('scripts') + + @include('forms.datatable-vertical') + @include('forms.delete-modal') +@endpush diff --git a/tests/Feature/Data/SuplemenControllerRequestTest.php b/tests/Feature/Data/SuplemenControllerRequestTest.php new file mode 100644 index 0000000000..434e7dee28 --- /dev/null +++ b/tests/Feature/Data/SuplemenControllerRequestTest.php @@ -0,0 +1,184 @@ +withViewErrors([]); + $this->withoutMiddleware([ + Authenticate::class, + RoleMiddleware::class, + PermissionMiddleware::class, + CompleteProfile::class, + GlobalShareMiddleware::class, + ]); + + SettingAplikasi::updateOrCreate( + ['key' => 'sinkronisasi_database_gabungan'], + ['value' => '0'] + ); +}); + +test('store membuat data suplemen dengan data valid', function () { + $response = $this->post(route('data.data-suplemen.store'), [ + 'nama' => 'Suplemen Valid', + 'sasaran' => '1', + 'keterangan' => 'Keterangan valid', + ]); + + $response->assertRedirect(route('data.data-suplemen.index')); + $response->assertSessionHas('success', 'Data Suplemen berhasil ditambah!'); + + $this->assertDatabaseHas('das_suplemen', [ + 'nama' => 'Suplemen Valid', + 'slug' => 'suplemen-valid', + 'sasaran' => '1', + ]); +}); + +test('store gagal ketika nama kosong', function () { + $response = $this->post(route('data.data-suplemen.store'), [ + 'nama' => '', + 'sasaran' => '1', + ]); + + $response->assertSessionHasErrors(['nama']); +}); + +test('store gagal ketika sasaran tidak valid', function () { + $response = $this->post(route('data.data-suplemen.store'), [ + 'nama' => 'Suplemen Sasaran', + 'sasaran' => '3', + ]); + + $response->assertSessionHasErrors(['sasaran']); +}); + +test('update mengubah data suplemen saat data valid', function () { + $suplemen = Suplemen::create([ + 'nama' => 'Suplemen Lama', + 'slug' => 'suplemen-lama', + 'sasaran' => 1, + ]); + + $response = $this->put(route('data.data-suplemen.update', $suplemen->id), [ + 'nama' => 'Suplemen Baru', + 'sasaran' => '2', + 'keterangan' => 'Keterangan baru', + ]); + + $response->assertRedirect(route('data.data-suplemen.index')); + $response->assertSessionHas('success', 'Data Suplemen berhasil diubah!'); + + $this->assertDatabaseHas('das_suplemen', [ + 'id' => $suplemen->id, + 'nama' => 'Suplemen Baru', + 'slug' => 'suplemen-baru', + 'sasaran' => '2', + ]); +}); + +test('storeDetail membuat anggota suplemen saat data valid', function () { + $suplemen = Suplemen::create([ + 'nama' => 'Suplemen Anggota', + 'slug' => 'suplemen-anggota', + 'sasaran' => 1, + ]); + $penduduk = Penduduk::factory()->create(); + + $response = $this->post(route('data.data-suplemen.storedetail'), [ + 'suplemen_id' => $suplemen->id, + 'penduduk_id' => $penduduk->id, + 'keterangan' => 'Anggota baru', + ]); + + $response->assertRedirect(route('data.data-suplemen.show', $suplemen->id)); + $response->assertSessionHas('success', 'Anggota Suplemen berhasil ditambah!'); + + expect(SuplemenTerdata::where('suplemen_id', $suplemen->id)->count())->toBe(1); +}); + +test('storeDetail gagal ketika sumber penduduk kosong dengan pesan kustom', function () { + $suplemen = Suplemen::create([ + 'nama' => 'Suplemen Anggota', + 'slug' => 'suplemen-anggota', + 'sasaran' => 1, + ]); + + $response = $this->post(route('data.data-suplemen.storedetail'), [ + 'suplemen_id' => $suplemen->id, + 'keterangan' => 'Tanpa penduduk', + ]); + + $response->assertSessionHasErrors(['penduduk_id']); + + $errors = session('errors'); + expect($errors->first('penduduk_id'))->toBe('isian warga atau penduduk wajib diisi'); +}); + +test('updateDetail mengubah anggota suplemen saat data valid', function () { + $suplemen = Suplemen::create([ + 'nama' => 'Suplemen Anggota', + 'slug' => 'suplemen-anggota', + 'sasaran' => 1, + ]); + $penduduk = Penduduk::factory()->create(); + $terdata = SuplemenTerdata::create([ + 'suplemen_id' => $suplemen->id, + 'penduduk_id' => $penduduk->id, + 'keterangan' => 'Keterangan awal', + ]); + + $response = $this->put(route('data.data-suplemen.updatedetail', $terdata->id), [ + 'suplemen_id' => $suplemen->id, + 'penduduk_id' => $penduduk->id, + 'keterangan' => 'Keterangan diubah', + ]); + + $response->assertRedirect(route('data.data-suplemen.show', $suplemen->id)); + $response->assertSessionHas('success', 'Anggota Suplemen berhasil diubah!'); + + $this->assertDatabaseHas('das_suplemen_terdata', [ + 'id' => $terdata->id, + 'keterangan' => 'Keterangan diubah', + ]); +}); diff --git a/tests/Feature/Data/SuplemenGabunganTest.php b/tests/Feature/Data/SuplemenGabunganTest.php new file mode 100644 index 0000000000..83935574d8 --- /dev/null +++ b/tests/Feature/Data/SuplemenGabunganTest.php @@ -0,0 +1,392 @@ + 'sinkronisasi_database_gabungan'], + ['value' => $aktif ? '1' : '0'] + ); + + if ($aktif) { + SettingAplikasi::updateOrCreate( + ['key' => 'api_server_database_gabungan'], + ['value' => 'https://api.example.com'] + ); + SettingAplikasi::updateOrCreate( + ['key' => 'api_key_database_gabungan'], + ['value' => 'test-api-key'] + ); + } +} + +/** + * Fake seluruh panggilan API database gabungan. + * + * Constructor controller memanggil DesaService::listDesa() sehingga setiap + * request saat mode gabungan aktif wajib memiliki stub catch-all. + */ +function fakeGabunganApi(array $routes = []): void +{ + Http::fake(array_merge($routes, [ + '*' => Http::response([ + 'data' => [], + 'meta' => ['pagination' => ['total' => 0]], + ], 200), + ])); +} + +beforeEach(function () { + $this->withViewErrors([]); + $this->withoutMiddleware([ + Authenticate::class, + RoleMiddleware::class, + PermissionMiddleware::class, + CompleteProfile::class, + GlobalShareMiddleware::class, + ]); + + aktifkanDatabaseGabungan(false); + + $this->suplemen = Suplemen::create([ + 'nama' => 'Suplemen Gabungan Test', + 'slug' => 'suplemen-gabungan-test', + 'sasaran' => 1, + 'keterangan' => 'Testing database gabungan', + ]); +}); + +test('show menampilkan view gabungan saat database gabungan aktif', function () { + aktifkanDatabaseGabungan(true); + fakeGabunganApi(); + + $response = $this->get(route('data.data-suplemen.show', $this->suplemen->id)); + + $response->assertStatus(200); + $response->assertViewIs('data.data_suplemen.gabungan.show'); + $response->assertViewHas('suplemen', fn (Suplemen $suplemen) => $suplemen->id === $this->suplemen->id); +}); + +test('show menampilkan view lokal saat database gabungan tidak aktif', function () { + $response = $this->get(route('data.data-suplemen.show', $this->suplemen->id)); + + $response->assertStatus(200); + $response->assertViewIs('data.data_suplemen.show'); + $response->assertViewHas('suplemen', fn (Suplemen $suplemen) => $suplemen->id === $this->suplemen->id); +}); + +test('createDetail menampilkan view gabungan saat database gabungan aktif', function () { + aktifkanDatabaseGabungan(true); + fakeGabunganApi(); + + $response = $this->get(route('data.data-suplemen.createdetail', $this->suplemen->id)); + + $response->assertStatus(200); + $response->assertViewIs('data.data_suplemen.gabungan.create_detail'); + $response->assertViewHas('isDatabaseGabungan', true); + $response->assertSee('api.example.com/api/v1/opendk/sync-penduduk-opendk'); + $response->assertSee('filter[kode_desa]'); + $response->assertSee('select2({'); + $response->assertSee('minimumInputLength: 0'); + $response->assertSee("$('#penduduk_id_gabungan')", false); +}); + +test('createDetail menampilkan view lokal saat database gabungan tidak aktif', function () { + $response = $this->get(route('data.data-suplemen.createdetail', $this->suplemen->id)); + + $response->assertStatus(200); + $response->assertViewIs('data.data_suplemen.create_detail'); + $response->assertViewHas('isDatabaseGabungan', false); +}); + +test('storeDetail menyimpan penduduk dari database gabungan saat gabungan aktif', function () { + aktifkanDatabaseGabungan(true); + fakeGabunganApi(); + + $response = $this->post(route('data.data-suplemen.storedetail'), [ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id_gabungan' => 99, + 'keterangan' => 'Anggota dari gabungan', + ]); + + $response->assertRedirect(route('data.data-suplemen.show', $this->suplemen->id)); + $response->assertSessionHas('success', 'Anggota Suplemen berhasil ditambah!'); + + $this->assertDatabaseHas('das_suplemen_terdata', [ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => null, + 'penduduk_id_gabungan' => 99, + 'keterangan' => 'Anggota dari gabungan', + ]); +}); + +test('storeDetail menyimpan penduduk lokal saat database gabungan tidak aktif', function () { + $penduduk = Penduduk::factory()->create(); + + $response = $this->post(route('data.data-suplemen.storedetail'), [ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $penduduk->id, + 'keterangan' => 'Anggota lokal', + ]); + + $response->assertRedirect(route('data.data-suplemen.show', $this->suplemen->id)); + $response->assertSessionHas('success', 'Anggota Suplemen berhasil ditambah!'); + + $this->assertDatabaseHas('das_suplemen_terdata', [ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $penduduk->id, + 'penduduk_id_gabungan' => null, + ]); +}); + +test('storeDetail gagal ketika penduduk lokal dan gabungan keduanya diisi', function () { + $penduduk = Penduduk::factory()->create(); + + $response = $this->post(route('data.data-suplemen.storedetail'), [ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $penduduk->id, + 'penduduk_id_gabungan' => 99, + ]); + + $response->assertSessionHasErrors(['penduduk_id_gabungan']); +}); + +test('getDataSuplemenTerdata menggabungkan anggota lokal dan API database gabungan', function () { + aktifkanDatabaseGabungan(true); + fakeGabunganApi([ + 'https://api.example.com/api/v1/opendk/suplemen-terdata-datatable/*' => Http::response([ + 'data' => [ + [ + 'id' => 100, + 'attributes' => [ + 'nama_desa' => 'Desa API', + 'no_kk' => '1234567890123456', + 'nik' => '1234567890123457', + 'nama' => 'Anggota Dari API', + 'tempat_lahir' => 'Jakarta', + 'tanggal_lahir' => '1990-01-01', + 'sex' => 1, + 'alamat' => 'Alamat API', + 'keterangan' => 'Terdata desa', + ], + ], + ], + 'meta' => ['pagination' => ['total' => 1]], + ], 200), + ]); + + $penduduk = Penduduk::factory()->create(['nama' => 'Anggota Lokal']); + SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $penduduk->id, + 'keterangan' => 'Anggota lokal', + ]); + + $response = $this->postJson( + route('data.data-suplemen.getsuplementerdata', $this->suplemen->id), + [], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(200); + $data = collect($response->json('data')); + + expect($data->pluck('keterangan'))->toContain('Anggota lokal') + ->and($data->pluck('keterangan'))->toContain('Terdata desa') + ->and($data->pluck('penduduk.nama'))->toContain('Anggota Lokal') + ->and($data->pluck('penduduk.nama'))->toContain('Anggota Dari API'); +}); + +test('getDataSuplemenTerdata menyelesaikan penduduk gabungan lokal dalam satu request batch', function () { + aktifkanDatabaseGabungan(true); + + $batchCalls = 0; + $sentIds = null; + + Http::fake([ + 'https://api.example.com/api/v1/opendk/sync-penduduk-opendk*' => function (\Illuminate\Http\Client\Request $request) use (&$batchCalls, &$sentIds) { + $batchCalls++; + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + $sentIds = $query['filter']['id_penduduk'] ?? null; + + return Http::response([ + 'data' => [ + ['id' => 500, 'attributes' => ['nama' => 'Penduduk Gabungan 1', 'nik' => '123', 'sex' => 1]], + ['id' => 501, 'attributes' => ['nama' => 'Penduduk Gabungan 2', 'nik' => '124', 'sex' => 2]], + ], + 'meta' => ['pagination' => ['total' => 2]], + ], 200); + }, + 'https://api.example.com/api/v1/opendk/suplemen-terdata-datatable/*' => Http::response([ + 'data' => [], + 'meta' => ['pagination' => ['total' => 0]], + ], 200), + '*' => Http::response([ + 'data' => [], + 'meta' => ['pagination' => ['total' => 0]], + ], 200), + ]); + + SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id_gabungan' => 500, + 'keterangan' => 'A1', + ]); + SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id_gabungan' => 501, + 'keterangan' => 'A2', + ]); + + $response = $this->postJson( + route('data.data-suplemen.getsuplementerdata', $this->suplemen->id), + [], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(200); + $data = collect($response->json('data')); + + expect($batchCalls)->toBe(1) + ->and($sentIds)->toContain('500') + ->and($sentIds)->toContain('501') + ->and($data->pluck('penduduk.nama'))->toContain('Penduduk Gabungan 1') + ->and($data->pluck('penduduk.nama'))->toContain('Penduduk Gabungan 2'); +}); + +test('editDetail menampilkan view gabungan saat database gabungan aktif', function () { + aktifkanDatabaseGabungan(true); + fakeGabunganApi([ + 'https://api.example.com/api/v1/opendk/sync-penduduk-opendk*' => Http::response([ + 'data' => [ + [ + 'id' => 99, + 'attributes' => [ + 'nama' => 'Penduduk Gabungan Edit', + 'nik' => '1234567890123456', + 'sex' => 1, + 'config' => ['nama_desa' => 'Desa Edit'], + ], + ], + ], + 'meta' => ['pagination' => ['total' => 1]], + ], 200), + ]); + + $terdata = SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id_gabungan' => 99, + 'keterangan' => 'Anggota gabungan', + ]); + + $response = $this->get(route('data.data-suplemen.editdetail', [$terdata->id, $this->suplemen->id])); + + $response->assertStatus(200); + $response->assertViewIs('data.data_suplemen.gabungan.edit_detail'); + $response->assertViewHas('isDatabaseGabungan', true); + $response->assertSee('Penduduk Gabungan Edit'); + $response->assertSee('sync-penduduk-opendk'); + $response->assertSee('filter[kode_desa]'); +}); + +test('editDetail menampilkan view lokal saat database gabungan tidak aktif', function () { + $penduduk = Penduduk::factory()->create(['nama' => 'Anggota Lokal Edit']); + $terdata = SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $penduduk->id, + 'keterangan' => 'Anggota lokal', + ]); + + $response = $this->get(route('data.data-suplemen.editdetail', [$terdata->id, $this->suplemen->id])); + + $response->assertStatus(200); + $response->assertViewIs('data.data_suplemen.edit_detail'); + $response->assertViewHas('isDatabaseGabungan', false); +}); + +test('updateDetail memperbarui anggota gabungan saat gabungan aktif', function () { + aktifkanDatabaseGabungan(true); + fakeGabunganApi(); + + $terdata = SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id_gabungan' => 99, + 'keterangan' => 'Keterangan awal', + ]); + + $response = $this->put(route('data.data-suplemen.updatedetail', $terdata->id), [ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id_gabungan' => 100, + 'keterangan' => 'Keterangan diubah', + ]); + + $response->assertRedirect(route('data.data-suplemen.show', $this->suplemen->id)); + $response->assertSessionHas('success', 'Anggota Suplemen berhasil diubah!'); + + $this->assertDatabaseHas('das_suplemen_terdata', [ + 'id' => $terdata->id, + 'penduduk_id' => null, + 'penduduk_id_gabungan' => 100, + 'keterangan' => 'Keterangan diubah', + ]); +}); + +test('destroyDetail menghapus anggota saat gabungan aktif', function () { + aktifkanDatabaseGabungan(true); + fakeGabunganApi(); + + $terdata = SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id_gabungan' => 99, + 'keterangan' => 'Anggota gabungan', + ]); + + $response = $this->delete(route('data.data-suplemen.destroydetail', [$terdata->id, $this->suplemen->id])); + + $response->assertRedirect(route('data.data-suplemen.show', $this->suplemen->id)); + $response->assertSessionHas('success', 'Anggota Suplemen berhasil dihapus!'); + + $this->assertDatabaseMissing('das_suplemen_terdata', ['id' => $terdata->id]); +}); diff --git a/tests/Unit/Services/PendudukServiceTest.php b/tests/Unit/Services/PendudukServiceTest.php index de4fabc913..f67a6648d6 100644 --- a/tests/Unit/Services/PendudukServiceTest.php +++ b/tests/Unit/Services/PendudukServiceTest.php @@ -1,8 +1,8 @@ toBeNull(); }); +it('can fetch multiple penduduk from gabungan by ids', function () { + $sentParams = null; + + Http::fake([ + '*/api/v1/opendk/sync-penduduk-opendk*' => function (\Illuminate\Http\Client\Request $request) use (&$sentParams) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $sentParams); + + return Http::response([ + 'data' => [ + ['id' => 500, 'attributes' => ['nama' => 'John Doe', 'nik' => '1234567890123456']], + ['id' => 501, 'attributes' => ['nama' => 'Jane Doe', 'nik' => '1234567890123457']], + ], + ], 200); + }, + ]); + + $service = new PendudukService(); + $result = $service->pendudukGabunganByIds([500, 501]); + + expect($result)->toHaveCount(2); + expect($result->pluck('id'))->toContain(500); + expect($result->pluck('id'))->toContain(501); + expect($sentParams['filter']['id_penduduk'])->toBe(['500', '501']); +}); + +it('returns empty collection when gabungan ids empty', function () { + $service = new PendudukService(); + + expect($service->pendudukGabunganByIds([]))->toHaveCount(0); +}); + it('can apply filters to jumlah penduduk', function () { Http::fake([ '*/api/v1/opendk/sync-penduduk-opendk*' => Http::response([ @@ -372,4 +403,4 @@ expect($exportData->first()['umur'])->toBe(''); expect($exportData->first()['pekerjaan'])->toBe(''); expect($exportData->first()['status_kawin'])->toBe(''); -}); \ No newline at end of file +}); From 0c9eee64de3d5a3c267d966efaa29495453b6171 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 7 Sep 2026 11:12:17 +0700 Subject: [PATCH 08/52] perbaikan path --- resources/views/data/laporan-penduduk/gabungan/index.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/data/laporan-penduduk/gabungan/index.blade.php b/resources/views/data/laporan-penduduk/gabungan/index.blade.php index cfca0ad8b9..84a843b7d1 100644 --- a/resources/views/data/laporan-penduduk/gabungan/index.blade.php +++ b/resources/views/data/laporan-penduduk/gabungan/index.blade.php @@ -99,7 +99,7 @@ const _url = data.attributes.path === undefined ? `{{ route('data.laporan-penduduk.export-excel.by-id', ['data' => '__DATA__']) }}` .replace('__DATA__', jsonData) : - `asset('storage/laporan_penduduk')/${data.nama_file}` + data.attributes.path const _disabled = data.attributes.path === undefined ? 'disabled' : '' return ` From 5f76ddb906d27b70f0b88df576069c029646891b Mon Sep 17 00:00:00 2001 From: Habibie Date: Mon, 7 Sep 2026 12:43:55 +0700 Subject: [PATCH 09/52] Sesuaikan Pratinjau File PDF dan Gambar pada Menu Prosedur, Regulasi, dan Potensi --- .../Informasi/PotensiController.php | 32 ++-- .../Informasi/ProsedurController.php | 39 ++--- .../Informasi/RegulasiController.php | 13 +- app/Http/Middleware/SecurityHeaders.php | 4 +- app/Http/Requests/PotensiRequest.php | 2 +- app/Models/Potensi.php | 21 ++- app/Models/Prosedur.php | 10 +- app/Models/Regulasi.php | 20 ++- app/Traits/HandlesFileUpload.php | 52 ++++-- ...001_add_mime_type_to_das_potensi_table.php | 32 ++++ helpers/general_helper.php | 36 +++-- .../views/backend/event/form_create.blade.php | 45 +++--- .../views/backend/event/form_edit.blade.php | 45 +++--- .../form_dokumen/form_edit.blade.php | 2 +- .../views/informasi/potensi/create.blade.php | 35 ++-- .../views/informasi/potensi/edit.blade.php | 35 ++-- .../informasi/potensi/form_create.blade.php | 7 +- .../informasi/potensi/form_edit.blade.php | 15 +- .../views/informasi/potensi/show.blade.php | 15 +- .../views/informasi/prosedur/create.blade.php | 45 +++--- .../views/informasi/prosedur/edit.blade.php | 45 +++--- .../views/informasi/prosedur/index.blade.php | 9 -- .../views/informasi/prosedur/show.blade.php | 21 ++- .../informasi/regulasi/form_create.blade.php | 62 ++++--- .../informasi/regulasi/form_update.blade.php | 79 +++++---- .../views/informasi/regulasi/show.blade.php | 23 ++- routes/web.php | 1 - tests/Feature/FilePreviewFeatureTest.php | 152 ++++++++++++++++++ tests/Unit/FilePreviewSecurityTest.php | 107 ++++++++++++ .../views/pages/potensi/show.blade.php | 8 +- 30 files changed, 720 insertions(+), 292 deletions(-) create mode 100644 database/migrations/2026_09_07_000001_add_mime_type_to_das_potensi_table.php create mode 100644 tests/Feature/FilePreviewFeatureTest.php create mode 100644 tests/Unit/FilePreviewSecurityTest.php diff --git a/app/Http/Controllers/Informasi/PotensiController.php b/app/Http/Controllers/Informasi/PotensiController.php index a2498f43ff..c3110b4e3f 100644 --- a/app/Http/Controllers/Informasi/PotensiController.php +++ b/app/Http/Controllers/Informasi/PotensiController.php @@ -31,11 +31,11 @@ namespace App\Http\Controllers\Informasi; +use App\Http\Controllers\Controller; +use App\Http\Requests\PotensiRequest; use App\Models\Potensi; use App\Models\TipePotensi; use App\Traits\HandlesFileUpload; -use App\Http\Controllers\Controller; -use App\Http\Requests\PotensiRequest; use Illuminate\Support\Facades\Log; use Yajra\DataTables\Facades\DataTables; @@ -103,6 +103,10 @@ public function store(PotensiRequest $request) $this->handleFileUpload($request, $input, 'file_gambar', 'potensi_kecamatan'); + if ($request->hasFile('file_gambar')) { + $input['mime_type'] = $request->file('file_gambar')->getClientMimeType(); + } + Potensi::create($input); } catch (\Exception $e) { Log::error('Potensi creation failed', [ @@ -139,6 +143,10 @@ public function update(PotensiRequest $request, Potensi $potensi) $this->handleFileUpload($request, $input, 'file_gambar', 'potensi_kecamatan'); + if ($request->hasFile('file_gambar')) { + $input['mime_type'] = $request->file('file_gambar')->getClientMimeType(); + } + $potensi->update($input); } catch (\Exception $e) { Log::error('Potensi update failed', [ @@ -175,25 +183,13 @@ public function destroy(Potensi $potensi) public function download(Potensi $potensi) { try { - $path = $potensi->file_gambar; - if (empty($path)) { - return back()->with('error', 'Dokumen potensi tidak ditemukan'); - } + $filePath = $this->resolveSecureFilePath($potensi->file_gambar); - if (file_exists(public_path($path))) { - return response()->download(public_path($path)); - } - - if (file_exists(base_path('public/' . $path))) { - return response()->download(base_path('public/' . $path)); + if (!$filePath) { + return back()->with('error', 'Dokumen potensi tidak ditemukan'); } - $storagePath = storage_path('app/public/' . str_replace('storage/', '', $path)); - if (file_exists($storagePath)) { - return response()->download($storagePath); - } - - return back()->with('error', 'Dokumen potensi tidak ditemukan'); + return response()->download($filePath); } catch (\Exception $e) { Log::error('Potensi download failed', [ 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Informasi/ProsedurController.php b/app/Http/Controllers/Informasi/ProsedurController.php index 73ec08a3bb..5d4ff1b84c 100644 --- a/app/Http/Controllers/Informasi/ProsedurController.php +++ b/app/Http/Controllers/Informasi/ProsedurController.php @@ -31,12 +31,12 @@ namespace App\Http\Controllers\Informasi; -use App\Models\Prosedur; -use Yajra\DataTables\DataTables; -use App\Traits\HandlesFileUpload; use App\Http\Controllers\Controller; use App\Http\Requests\ProsedurRequest; +use App\Models\Prosedur; +use App\Traits\HandlesFileUpload; use Illuminate\Support\Facades\Log; +use Yajra\DataTables\DataTables; class ProsedurController extends Controller { @@ -98,7 +98,6 @@ public function getDataProsedur() } $data['download_url'] = auth()->user()->can('access.informasi.prosedur.export') ? route('informasi.prosedur.download', $row->id) : null; - $data['preview_url'] = auth()->user()->can('access.informasi.prosedur.view') ? route('informasi.prosedur.preview', $row->id) : null; return view('forms.aksi', $data); }) @@ -196,7 +195,13 @@ public function destroy(Prosedur $prosedur) public function download(Prosedur $prosedur) { try { - return response()->download($prosedur->file_prosedur); + $filePath = $this->resolveSecureFilePath($prosedur->file_prosedur); + + if (!$filePath) { + return back()->with('error', 'Dokumen prosedur tidak ditemukan'); + } + + return response()->download($filePath); } catch (\Exception $e) { Log::error('Prosedur download failed', [ 'error' => $e->getMessage(), @@ -207,28 +212,4 @@ public function download(Prosedur $prosedur) return back()->with('error', 'Dokumen prosedur tidak ditemukan'); } } - - public function preview(Prosedur $prosedur) - { - $path = $prosedur->file_prosedur; - - if (empty($path)) { - abort(404, 'File tidak ditemukan.'); - } - - if (file_exists(public_path($path))) { - return response()->file(public_path($path)); - } - - if (file_exists(base_path('public/' . $path))) { - return response()->file(base_path('public/' . $path)); - } - - $storagePath = storage_path('app/public/' . str_replace('storage/', '', $path)); - if (file_exists($storagePath)) { - return response()->file($storagePath); - } - - abort(404, 'File tidak ditemukan.'); - } } diff --git a/app/Http/Controllers/Informasi/RegulasiController.php b/app/Http/Controllers/Informasi/RegulasiController.php index 580cd12662..d5785a5304 100644 --- a/app/Http/Controllers/Informasi/RegulasiController.php +++ b/app/Http/Controllers/Informasi/RegulasiController.php @@ -31,12 +31,12 @@ namespace App\Http\Controllers\Informasi; -use App\Models\Regulasi; -use App\Traits\HandlesFileUpload; use App\Http\Controllers\Controller; use App\Http\Requests\RegulasiRequest; use App\Http\Requests\RegulasiUpdateRequest; +use App\Models\Regulasi; use App\Models\TipeRegulasi; +use App\Traits\HandlesFileUpload; use Illuminate\Support\Facades\Log; use Yajra\DataTables\Facades\DataTables; @@ -124,7 +124,6 @@ public function update(RegulasiUpdateRequest $request, Regulasi $regulasi) $input['profil_id'] = $this->profil->id; $this->handleFileUpload($request, $input, 'file_regulasi', 'regulasi'); - if ($request->hasFile('file_regulasi')) { $input['mime_type'] = $request->file('file_regulasi')->getMimeType(); } @@ -167,7 +166,13 @@ public function destroy(Regulasi $regulasi) public function download(Regulasi $regulasi) { try { - return response()->download($regulasi->file_regulasi); + $filePath = $this->resolveSecureFilePath($regulasi->file_regulasi); + + if (!$filePath) { + return back()->with('error', 'Dokumen regulasi tidak ditemukan'); + } + + return response()->download($filePath); } catch (\Exception $e) { Log::error('Regulasi download failed', [ 'error' => $e->getMessage(), diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php index 3fa6c4b3b1..c7441b2932 100644 --- a/app/Http/Middleware/SecurityHeaders.php +++ b/app/Http/Middleware/SecurityHeaders.php @@ -41,7 +41,6 @@ class SecurityHeaders * Handle an incoming request. * * @param \Illuminate\Http\Request $request - * @return mixed */ public function handle($request, Closure $next) { @@ -51,8 +50,7 @@ public function handle($request, Closure $next) if (app()->environment('production')) { $localDomain = env('APP_URL', 'http://localhost'); $urlDatabaseGabungan = config('setting.api_server_database_gabungan'); - $response->headers->set('X-Content-Type-Options', 'nosniff'); - $response->headers->set('Content-Security-Policy', "default-src 'self';script-src 'self' https://pantau.opensid.my.id/ https://cdnjs.cloudflare.com/ajax/libs/tinymce/ https://cdn.jsdelivr.net/npm/ https://cdnjs.cloudflare.com/ajax/libs/numeral.js/2.0.6/numeral.min.js https://website-widgets.pages.dev/dist/sienna.min.js platform.twitter.com unpkg.com 'unsafe-inline' 'unsafe-eval';style-src 'self' https://www.tiny.cloud/ http://www.tinymce.com/css/codepen.min.css https://cdnjs.cloudflare.com/ajax/libs/tinymce/ https://cdn.jsdelivr.net/npm/ fonts.googleapis.com unpkg.com 'unsafe-inline';img-src 'self' * data:;font-src 'self' https://cdnjs.cloudflare.com/ajax/libs/tinymce/ https://cdn.jsdelivr.net/npm/ fonts.gstatic.com data:;connect-src 'self' ".$urlDatabaseGabungan." https://pantau.opensid.my.id/ ;media-src 'self';frame-src 'self' platform.twitter.com github.com *.youtube.com *.vimeo.com *.opensid.my.id;object-src 'none';base-uri 'self';report-uri"); + $response->headers->set('Content-Security-Policy', "default-src 'self';script-src 'self' https://pantau.opensid.my.id/ https://cdnjs.cloudflare.com/ajax/libs/tinymce/ https://cdn.jsdelivr.net/npm/ https://cdnjs.cloudflare.com/ajax/libs/numeral.js/2.0.6/numeral.min.js https://website-widgets.pages.dev/dist/sienna.min.js platform.twitter.com unpkg.com 'unsafe-inline' 'unsafe-eval';style-src 'self' https://www.tiny.cloud/ http://www.tinymce.com/css/codepen.min.css https://cdnjs.cloudflare.com/ajax/libs/tinymce/ https://cdn.jsdelivr.net/npm/ fonts.googleapis.com unpkg.com 'unsafe-inline';img-src 'self' * data: blob:;font-src 'self' https://cdnjs.cloudflare.com/ajax/libs/tinymce/ https://cdn.jsdelivr.net/npm/ fonts.gstatic.com data:;connect-src 'self' ".$urlDatabaseGabungan." https://pantau.opensid.my.id/ ;media-src 'self';frame-src 'self' blob: data: platform.twitter.com github.com *.youtube.com *.vimeo.com *.opensid.my.id;object-src 'self' blob:;frame-ancestors 'self';base-uri 'self';"); } return $response; diff --git a/app/Http/Requests/PotensiRequest.php b/app/Http/Requests/PotensiRequest.php index cdef15dea0..cba7f6789c 100644 --- a/app/Http/Requests/PotensiRequest.php +++ b/app/Http/Requests/PotensiRequest.php @@ -57,7 +57,7 @@ public function rules() 'nama_potensi' => 'required|string|max:200', 'deskripsi' => 'required|string', 'lokasi' => 'required|string|max:200', - 'file_gambar' => 'file|mimes:bmp,jpg,jpeg,gif,png' . (\App\Services\FileUploadService::isLimitEnabled() ? '|max:1024' : '') . '|valid_file', + 'file_gambar' => 'file|mimes:bmp,jpg,jpeg,gif,png,pdf' . (\App\Services\FileUploadService::isLimitEnabled() ? '|max:2048' : '') . '|valid_file', ]; } } diff --git a/app/Models/Potensi.php b/app/Models/Potensi.php index cf5fd259b0..5ad9bde69f 100644 --- a/app/Models/Potensi.php +++ b/app/Models/Potensi.php @@ -41,13 +41,6 @@ class Potensi extends Model protected $table = 'das_potensi'; - protected static function booted(): void - { - static::created(fn (Potensi $potensi) => PotensiChanged::dispatch($potensi)); - static::updated(fn (Potensi $potensi) => PotensiChanged::dispatch($potensi)); - static::deleted(fn (Potensi $potensi) => PotensiChanged::dispatch($potensi)); - } - protected $fillable = [ 'kategori_id', 'nama_potensi', @@ -56,6 +49,7 @@ protected static function booted(): void 'long', 'lat', 'file_gambar', + 'mime_type', ]; /** @@ -71,4 +65,17 @@ public function tipe() { return $this->hasOne(TipePotensi::class, 'id', 'kategori_id'); } + + public function getIsPdfAttribute(): bool + { + return str_contains(strtolower($this->mime_type ?? ''), 'pdf') + || str_ends_with(strtolower($this->file_gambar ?? ''), '.pdf'); + } + + protected static function booted(): void + { + static::created(fn (Potensi $potensi) => PotensiChanged::dispatch($potensi)); + static::updated(fn (Potensi $potensi) => PotensiChanged::dispatch($potensi)); + static::deleted(fn (Potensi $potensi) => PotensiChanged::dispatch($potensi)); + } } diff --git a/app/Models/Prosedur.php b/app/Models/Prosedur.php index d1a618d313..b1ea796c2a 100644 --- a/app/Models/Prosedur.php +++ b/app/Models/Prosedur.php @@ -32,15 +32,15 @@ namespace App\Models; use App\Traits\HandlesResourceDeletion; -use Illuminate\Database\Eloquent\Model; use Cviebrock\EloquentSluggable\Sluggable; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; class Prosedur extends Model { + use HandlesResourceDeletion; use HasFactory; use Sluggable; - use HandlesResourceDeletion; protected $table = 'das_prosedur'; @@ -72,5 +72,9 @@ public function sluggable(): array ]; } + public function getIsPdfAttribute(): bool + { + return str_contains(strtolower($this->mime_type ?? ''), 'pdf') + || str_ends_with(strtolower($this->file_prosedur ?? ''), '.pdf'); + } } - diff --git a/app/Models/Regulasi.php b/app/Models/Regulasi.php index db2a313e3f..542820c1ad 100644 --- a/app/Models/Regulasi.php +++ b/app/Models/Regulasi.php @@ -41,13 +41,6 @@ class Regulasi extends Model protected $table = 'das_regulasi'; - protected static function booted(): void - { - static::created(fn (Regulasi $regulasi) => RegulasiChanged::dispatch($regulasi)); - static::updated(fn (Regulasi $regulasi) => RegulasiChanged::dispatch($regulasi)); - static::deleted(fn (Regulasi $regulasi) => RegulasiChanged::dispatch($regulasi)); - } - protected $fillable = [ 'kecamatan_id', 'tipe_regulasi', @@ -70,4 +63,17 @@ public function tipe() { return $this->hasOne(TipeRegulasi::class, 'id', 'tipe_regulasi'); } + + public function getIsPdfAttribute(): bool + { + return str_contains(strtolower($this->mime_type ?? ''), 'pdf') + || str_ends_with(strtolower($this->file_regulasi ?? ''), '.pdf'); + } + + protected static function booted(): void + { + static::created(fn (Regulasi $regulasi) => RegulasiChanged::dispatch($regulasi)); + static::updated(fn (Regulasi $regulasi) => RegulasiChanged::dispatch($regulasi)); + static::deleted(fn (Regulasi $regulasi) => RegulasiChanged::dispatch($regulasi)); + } } diff --git a/app/Traits/HandlesFileUpload.php b/app/Traits/HandlesFileUpload.php index 5bdea81a7b..ff00826ff6 100644 --- a/app/Traits/HandlesFileUpload.php +++ b/app/Traits/HandlesFileUpload.php @@ -36,7 +36,7 @@ trait HandlesFileUpload { /** - * Daftar MIME types default yang diizinkan + * Daftar MIME types default yang diizinkan. */ protected array $defaultAllowedMimes = [ 'image' => ['jpeg', 'png', 'jpg', 'gif', 'svg', 'webp'], @@ -53,7 +53,9 @@ trait HandlesFileUpload * @param string $directory * @param bool $withDirectory * @param array $allowedMimes Optional list of allowed mime extensions + * * @return void + * * @throws \Illuminate\Validation\ValidationException */ public function handleFileUpload($request, &$input, $field = 'file', $directory = 'uploads', $withDirectory = true, array $allowedMimes = []) @@ -78,12 +80,45 @@ public function handleFileUpload($request, &$input, $field = 'file', $directory } } + /** + * Resolve file path secara aman dan pastikan berada dalam direktori yang diizinkan (mencegah Path Traversal). + */ + public function resolveSecureFilePath(?string $path): ?string + { + if (empty($path)) { + return null; + } + + $candidates = [ + public_path($path), + base_path('public/' . $path), + storage_path('app/public/' . str_replace('storage/', '', $path)), + ]; + + $allowedPublic = realpath(public_path()); + $allowedStorage = realpath(storage_path('app/public')); + + foreach ($candidates as $candidate) { + if (file_exists($candidate)) { + $real = realpath($candidate); + if ($real) { + $isAllowedPublic = $allowedPublic && str_starts_with($real, $allowedPublic); + $isAllowedStorage = $allowedStorage && str_starts_with($real, $allowedStorage); + if ($isAllowedPublic || $isAllowedStorage) { + return $real; + } + } + } + } + + return null; + } + /** * Validasi MIME type file. * - * @param UploadedFile $file * @param array $allowedMimes List of allowed extensions (e.g., ['jpg', 'png', 'pdf']) - * @return void + * * @throws \Illuminate\Validation\ValidationException */ protected function validateMimeType(UploadedFile $file, array $allowedMimes): void @@ -92,7 +127,7 @@ protected function validateMimeType(UploadedFile $file, array $allowedMimes): vo if (!in_array($extension, $allowedMimes)) { throw \Illuminate\Validation\ValidationException::withMessages([ - 'file' => "Tipe file tidak diizinkan. Tipe yang diizinkan: " . implode(', ', $allowedMimes), + 'file' => 'Tipe file tidak diizinkan. Tipe yang diizinkan: ' . implode(', ', $allowedMimes), ]); } @@ -102,16 +137,13 @@ protected function validateMimeType(UploadedFile $file, array $allowedMimes): vo if (!empty($allowedMimeTypes) && !in_array($mimeType, $allowedMimeTypes)) { throw \Illuminate\Validation\ValidationException::withMessages([ - 'file' => "MIME type file tidak valid. Pastikan file yang diupload sesuai dengan ekstensinya.", + 'file' => 'MIME type file tidak valid. Pastikan file yang diupload sesuai dengan ekstensinya.', ]); } } /** * Generate nama file yang aman untuk penyimpanan. - * - * @param UploadedFile $file - * @return string */ protected function generateSafeFileName(UploadedFile $file): string { @@ -120,9 +152,6 @@ protected function generateSafeFileName(UploadedFile $file): string /** * Convert extensions to MIME types. - * - * @param array $extensions - * @return array */ protected function extensionsToMimeTypes(array $extensions): array { @@ -158,7 +187,6 @@ protected function extensionsToMimeTypes(array $extensions): array * Mendapatkan daftar MIME types berdasarkan kategori. * * @param string $category 'image', 'document', atau 'archive' - * @return array */ protected function getAllowedMimesByCategory(string $category): array { diff --git a/database/migrations/2026_09_07_000001_add_mime_type_to_das_potensi_table.php b/database/migrations/2026_09_07_000001_add_mime_type_to_das_potensi_table.php new file mode 100644 index 0000000000..b94dc4c0fc --- /dev/null +++ b/database/migrations/2026_09_07_000001_add_mime_type_to_das_potensi_table.php @@ -0,0 +1,32 @@ +string('mime_type', 50)->nullable()->after('file_gambar'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('das_potensi', function (Blueprint $table) { + if (Schema::hasColumn('das_potensi', 'mime_type')) { + $table->dropColumn('mime_type'); + } + }); + } +}; diff --git a/helpers/general_helper.php b/helpers/general_helper.php index 8d6fc0f01d..e4fb43b0f5 100644 --- a/helpers/general_helper.php +++ b/helpers/general_helper.php @@ -42,7 +42,7 @@ use voku\helper\AntiXSS; use willvincent\Feeds\Facades\FeedsFacade; -/** +/* * Parsing url image dari rss feed description * * @param string $content @@ -60,9 +60,10 @@ function get_tag_image(string $content) } /** - * { function_description } + * { function_description }. * * @param $parent_id The parent identifier + * * @return ( description_of_the_return_value ) */ function define_child($parent_id) @@ -73,10 +74,11 @@ function define_child($parent_id) } /** - * { function_description } + * { function_description }. * * @param $id The identifier * @param $permission The permission + * * @return ( description_of_the_return_value ) */ function permission_val($id, $permission) @@ -98,9 +100,10 @@ function permission_name($name) } /** - * Generate Password + * Generate Password. * * @param int $length Length Character + * * @return string voucher */ function generate_password($length = 6) @@ -122,7 +125,7 @@ function generate_password($length = 6) } /** - * Respon Meta + * Respon Meta. * * @param $message The message */ @@ -389,7 +392,7 @@ function terbilang($angka) if (! function_exists('sudahInstal')) { /** - * Cek apakah sudah install OpenDK atau belum + * Cek apakah sudah install OpenDK atau belum. * * @return bool True jika sudah install, False jika belum install */ @@ -405,7 +408,7 @@ function sudahInstal(): bool if (! function_exists('isActive')) { /** - * Helper function untuk installer views - cek apakah route aktif + * Helper function untuk installer views - cek apakah route aktif. * * @param string $routeName */ @@ -429,7 +432,7 @@ function isActive($routeName): string } } -/** +/* * Cek akses website. * * @param string $url @@ -488,7 +491,7 @@ function parsedown($params = null) if (! function_exists('theme_new')) { /** - * Ambil model tema + * Ambil model tema. * * @return Theme */ @@ -753,3 +756,18 @@ function kembalikanSlug($str): ?string // Ganti '-' dengan spasi dan hilangkan titik '.' return str_replace(['-', '.'], [' ', ''], $str); } + +if (! function_exists('is_pdf')) { + function is_pdf($path = null, $mime = null): bool + { + if ($mime && str_contains(strtolower($mime), 'pdf')) { + return true; + } + + if ($path && str_ends_with(strtolower($path), '.pdf')) { + return true; + } + + return false; + } +} diff --git a/resources/views/backend/event/form_create.blade.php b/resources/views/backend/event/form_create.blade.php index a6656889ca..e72efaa426 100644 --- a/resources/views/backend/event/form_create.blade.php +++ b/resources/views/backend/event/form_create.blade.php @@ -40,32 +40,41 @@ - @include('components.modal-preview-surat') @include('forms.datatable-vertical') @include('forms.delete-modal') @endpush diff --git a/resources/views/informasi/prosedur/show.blade.php b/resources/views/informasi/prosedur/show.blade.php index 291de99051..d9a2486f2f 100644 --- a/resources/views/informasi/prosedur/show.blade.php +++ b/resources/views/informasi/prosedur/show.blade.php @@ -18,21 +18,28 @@
- + @if (!empty($prosedur->file_prosedur)) + +   Unduh + + @endif
- @if (isset($prosedur->file_prosedur) && $prosedur->mime_type != 'pdf') - - @endif - - @if (isset($prosedur->file_prosedur) && $prosedur->mime_type == 'pdf') - + @if (!empty($prosedur->file_prosedur)) + @if ($prosedur->is_pdf) + + @else + + @endif @endif
diff --git a/resources/views/informasi/regulasi/form_create.blade.php b/resources/views/informasi/regulasi/form_create.blade.php index 7aacb9cc3e..d9a8ae8cf8 100644 --- a/resources/views/informasi/regulasi/form_create.blade.php +++ b/resources/views/informasi/regulasi/form_create.blade.php @@ -19,20 +19,12 @@
- + + +

- - @if (isset($regulasi->file_regulasi) && $regulasi->mime_type != 'pdf') - - @endif - - @if (isset($regulasi->file_regulasi) && $regulasi->mime_type == 'pdf') - - @endif - - - - + +
@@ -45,34 +37,38 @@ + + @include('forms.datatable-vertical') @include('forms.delete-modal') @endpush diff --git a/resources/views/data/data_suplemen/show.blade.php b/resources/views/data/data_suplemen/show.blade.php index 6b9361fb7f..a4778e1ccf 100644 --- a/resources/views/data/data_suplemen/show.blade.php +++ b/resources/views/data/data_suplemen/show.blade.php @@ -46,6 +46,7 @@

Daftar Anggota Suplemen + @include('layouts.fragments.list-desa')
@@ -131,6 +132,20 @@ class: 'text-center', }); }); + + @include('forms.datatable-vertical') @include('forms.delete-modal') @endpush diff --git a/resources/views/forms/btn-social.blade.php b/resources/views/forms/btn-social.blade.php index ddf9bd401a..544b7e0a81 100644 --- a/resources/views/forms/btn-social.blade.php +++ b/resources/views/forms/btn-social.blade.php @@ -15,7 +15,7 @@ @endif @if (isset($export_url)) - + diff --git a/tests/Feature/Data/SuplemenControllerRequestTest.php b/tests/Feature/Data/SuplemenControllerRequestTest.php index 434e7dee28..e8b8ba125a 100644 --- a/tests/Feature/Data/SuplemenControllerRequestTest.php +++ b/tests/Feature/Data/SuplemenControllerRequestTest.php @@ -127,6 +127,7 @@ $response = $this->post(route('data.data-suplemen.storedetail'), [ 'suplemen_id' => $suplemen->id, + 'desa_id' => $penduduk->desa_id, 'penduduk_id' => $penduduk->id, 'keterangan' => 'Anggota baru', ]); @@ -170,6 +171,7 @@ $response = $this->put(route('data.data-suplemen.updatedetail', $terdata->id), [ 'suplemen_id' => $suplemen->id, + 'desa_id' => $penduduk->desa_id, 'penduduk_id' => $penduduk->id, 'keterangan' => 'Keterangan diubah', ]); diff --git a/tests/Feature/Data/SuplemenGabunganTest.php b/tests/Feature/Data/SuplemenGabunganTest.php index 83935574d8..65e4d88e04 100644 --- a/tests/Feature/Data/SuplemenGabunganTest.php +++ b/tests/Feature/Data/SuplemenGabunganTest.php @@ -32,6 +32,7 @@ use App\Http\Middleware\Authenticate; use App\Http\Middleware\CompleteProfile; use App\Http\Middleware\GlobalShareMiddleware; +use App\Models\DataDesa; use App\Models\Penduduk; use App\Models\SettingAplikasi; use App\Models\Suplemen; @@ -147,6 +148,7 @@ function fakeGabunganApi(array $routes = []): void $response = $this->post(route('data.data-suplemen.storedetail'), [ 'suplemen_id' => $this->suplemen->id, + 'desa_id' => '3301010001', 'penduduk_id_gabungan' => 99, 'keterangan' => 'Anggota dari gabungan', ]); @@ -158,6 +160,7 @@ function fakeGabunganApi(array $routes = []): void 'suplemen_id' => $this->suplemen->id, 'penduduk_id' => null, 'penduduk_id_gabungan' => 99, + 'desa_id' => '3301010001', 'keterangan' => 'Anggota dari gabungan', ]); }); @@ -167,6 +170,7 @@ function fakeGabunganApi(array $routes = []): void $response = $this->post(route('data.data-suplemen.storedetail'), [ 'suplemen_id' => $this->suplemen->id, + 'desa_id' => $penduduk->desa_id, 'penduduk_id' => $penduduk->id, 'keterangan' => 'Anggota lokal', ]); @@ -178,6 +182,7 @@ function fakeGabunganApi(array $routes = []): void 'suplemen_id' => $this->suplemen->id, 'penduduk_id' => $penduduk->id, 'penduduk_id_gabungan' => null, + 'desa_id' => $penduduk->desa_id, ]); }); @@ -186,6 +191,7 @@ function fakeGabunganApi(array $routes = []): void $response = $this->post(route('data.data-suplemen.storedetail'), [ 'suplemen_id' => $this->suplemen->id, + 'desa_id' => $penduduk->desa_id, 'penduduk_id' => $penduduk->id, 'penduduk_id_gabungan' => 99, ]); @@ -193,7 +199,18 @@ function fakeGabunganApi(array $routes = []): void $response->assertSessionHasErrors(['penduduk_id_gabungan']); }); -test('getDataSuplemenTerdata menggabungkan anggota lokal dan API database gabungan', function () { +test('storeDetail gagal ketika desa_id tidak diisi', function () { + $penduduk = Penduduk::factory()->create(); + + $response = $this->post(route('data.data-suplemen.storedetail'), [ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $penduduk->id, + ]); + + $response->assertSessionHasErrors(['desa_id']); +}); + +test('getDataSuplemenTerdata tidak menggabungkan anggota dari API database gabungan', function () { aktifkanDatabaseGabungan(true); fakeGabunganApi([ 'https://api.example.com/api/v1/opendk/suplemen-terdata-datatable/*' => Http::response([ @@ -221,6 +238,7 @@ function fakeGabunganApi(array $routes = []): void SuplemenTerdata::create([ 'suplemen_id' => $this->suplemen->id, 'penduduk_id' => $penduduk->id, + 'desa_id' => $penduduk->desa_id, 'keterangan' => 'Anggota lokal', ]); @@ -234,9 +252,9 @@ function fakeGabunganApi(array $routes = []): void $data = collect($response->json('data')); expect($data->pluck('keterangan'))->toContain('Anggota lokal') - ->and($data->pluck('keterangan'))->toContain('Terdata desa') + ->not->toContain('Terdata desa') ->and($data->pluck('penduduk.nama'))->toContain('Anggota Lokal') - ->and($data->pluck('penduduk.nama'))->toContain('Anggota Dari API'); + ->not->toContain('Anggota Dari API'); }); test('getDataSuplemenTerdata menyelesaikan penduduk gabungan lokal dalam satu request batch', function () { @@ -296,6 +314,167 @@ function fakeGabunganApi(array $routes = []): void ->and($data->pluck('penduduk.nama'))->toContain('Penduduk Gabungan 2'); }); +test('getDataSuplemenTerdata mengabaikan item non-array pada respons batch', function () { + aktifkanDatabaseGabungan(true); + + Http::fake([ + 'https://api.example.com/api/v1/opendk/sync-penduduk-opendk*' => Http::response([ + 'data' => [ + ['id' => 500, 'attributes' => ['nama' => 'Penduduk Gabungan 1', 'nik' => '123', 'sex' => 1]], + false, + null, + ], + 'meta' => ['pagination' => ['total' => 3]], + ], 200), + 'https://api.example.com/api/v1/opendk/suplemen-terdata-datatable/*' => Http::response([ + 'data' => [], + 'meta' => ['pagination' => ['total' => 0]], + ], 200), + '*' => Http::response([ + 'data' => [], + 'meta' => ['pagination' => ['total' => 0]], + ], 200), + ]); + + SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id_gabungan' => 500, + 'keterangan' => 'A1', + ]); + + $response = $this->postJson( + route('data.data-suplemen.getsuplementerdata', $this->suplemen->id), + [], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(200); + $data = collect($response->json('data')); + + expect($data->pluck('penduduk.nama'))->toContain('Penduduk Gabungan 1'); +}); + +test('getDataSuplemenTerdata memfilter anggota lokal berdasarkan desa', function () { + aktifkanDatabaseGabungan(true); + fakeGabunganApi(); + + $desaA = DataDesa::create(['desa_id' => '3301010001', 'nama' => 'Desa A']); + $desaB = DataDesa::create(['desa_id' => '3301010002', 'nama' => 'Desa B']); + + $pendudukA = Penduduk::factory()->create(['desa_id' => $desaA->desa_id, 'nama' => 'Warga Desa A']); + $pendudukB = Penduduk::factory()->create(['desa_id' => $desaB->desa_id, 'nama' => 'Warga Desa B']); + + SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $pendudukA->id, + 'desa_id' => $pendudukA->desa_id, + 'keterangan' => 'A', + ]); + SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $pendudukB->id, + 'desa_id' => $pendudukB->desa_id, + 'keterangan' => 'B', + ]); + + $response = $this->postJson( + route('data.data-suplemen.getsuplementerdata', $this->suplemen->id), + ['desa' => $desaA->desa_id], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(200); + $data = collect($response->json('data')); + + expect($data->pluck('penduduk.nama'))->toContain('Warga Desa A') + ->not->toContain('Warga Desa B'); +}); + +test('getDataSuplemenTerdata tanpa filter desa menampilkan semua anggota lokal', function () { + $desaA = DataDesa::create(['desa_id' => '3301010001', 'nama' => 'Desa A']); + $desaB = DataDesa::create(['desa_id' => '3301010002', 'nama' => 'Desa B']); + + $pendudukA = Penduduk::factory()->create(['desa_id' => $desaA->desa_id, 'nama' => 'Warga Desa A']); + $pendudukB = Penduduk::factory()->create(['desa_id' => $desaB->desa_id, 'nama' => 'Warga Desa B']); + + SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $pendudukA->id, + 'desa_id' => $pendudukA->desa_id, + ]); + SuplemenTerdata::create([ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $pendudukB->id, + 'desa_id' => $pendudukB->desa_id, + ]); + + $response = $this->postJson( + route('data.data-suplemen.getsuplementerdata', $this->suplemen->id), + ['desa' => 'Semua'], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(200); + $data = collect($response->json('data')); + + expect($data->pluck('penduduk.nama'))->toContain('Warga Desa A') + ->toContain('Warga Desa B'); +}); + +test('storeDetail menyimpan desa_id dari penduduk database gabungan', function () { + aktifkanDatabaseGabungan(true); + fakeGabunganApi([ + 'https://api.example.com/api/v1/opendk/sync-penduduk-opendk*' => Http::response([ + 'data' => [ + [ + 'id' => 99, + 'attributes' => [ + 'nama' => 'Penduduk Gabungan', + 'nik' => '123', + 'sex' => 1, + 'config' => ['kode_desa' => '3301010001', 'nama_desa' => 'Desa API'], + ], + ], + ], + 'meta' => ['pagination' => ['total' => 1]], + ], 200), + ]); + + $response = $this->post(route('data.data-suplemen.storedetail'), [ + 'suplemen_id' => $this->suplemen->id, + 'desa_id' => '3301010001', + 'penduduk_id_gabungan' => 99, + 'keterangan' => 'Anggota dari gabungan', + ]); + + $response->assertRedirect(route('data.data-suplemen.show', $this->suplemen->id)); + + $this->assertDatabaseHas('das_suplemen_terdata', [ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id_gabungan' => 99, + 'desa_id' => '3301010001', + ]); +}); + +test('storeDetail menyimpan desa_id dari penduduk lokal', function () { + $penduduk = Penduduk::factory()->create(['desa_id' => '3301010003', 'nama' => 'Warga Lokal']); + + $response = $this->post(route('data.data-suplemen.storedetail'), [ + 'suplemen_id' => $this->suplemen->id, + 'desa_id' => $penduduk->desa_id, + 'penduduk_id' => $penduduk->id, + 'keterangan' => 'Anggota lokal', + ]); + + $response->assertRedirect(route('data.data-suplemen.show', $this->suplemen->id)); + + $this->assertDatabaseHas('das_suplemen_terdata', [ + 'suplemen_id' => $this->suplemen->id, + 'penduduk_id' => $penduduk->id, + 'desa_id' => '3301010003', + ]); +}); + test('editDetail menampilkan view gabungan saat database gabungan aktif', function () { aktifkanDatabaseGabungan(true); fakeGabunganApi([ @@ -358,6 +537,7 @@ function fakeGabunganApi(array $routes = []): void $response = $this->put(route('data.data-suplemen.updatedetail', $terdata->id), [ 'suplemen_id' => $this->suplemen->id, + 'desa_id' => '3301010001', 'penduduk_id_gabungan' => 100, 'keterangan' => 'Keterangan diubah', ]); diff --git a/tests/Feature/SuplemenExportTest.php b/tests/Feature/SuplemenExportTest.php index d57d48c9d3..7d5a555201 100644 --- a/tests/Feature/SuplemenExportTest.php +++ b/tests/Feature/SuplemenExportTest.php @@ -2,11 +2,14 @@ use App\Exports\ExportSuplemen; use App\Exports\ExportSuplemenTerdata; +use App\Exports\ExportSuplemenTerdataGabungan; use App\Models\DataDesa; use App\Models\Penduduk; use App\Models\Suplemen; use App\Models\SuplemenTerdata; +use App\Models\SettingAplikasi; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\Http; use Maatwebsite\Excel\Facades\Excel; uses(DatabaseTransactions::class); @@ -227,17 +230,17 @@ // Arrange: Buat data suplemen dan terdata Suplemen::query()->delete(); SuplemenTerdata::query()->delete(); - + $desa = DataDesa::factory()->create(); $penduduk = Penduduk::factory()->create(['desa_id' => $desa->desa_id]); - + $suplemen = Suplemen::create([ 'nama' => 'Test Suplemen Terdata', 'slug' => 'test-suplemen-terdata', 'sasaran' => 1, 'keterangan' => 'Test Keterangan' ]); - + SuplemenTerdata::create([ 'suplemen_id' => $suplemen->id, 'penduduk_id' => $penduduk->id, @@ -266,23 +269,118 @@ expect($collection)->toHaveCount(0); }); +test('export suplemen terdata with gabungan resolver fills penduduk relation', function () { + Suplemen::query()->delete(); + SuplemenTerdata::query()->delete(); + + Http::fake([ + 'https://api.example.com/api/v1/opendk/sync-penduduk-opendk*' => Http::response([ + 'data' => [ + [ + 'id' => 500, + 'attributes' => [ + 'nama' => 'Penduduk Dari API', + 'nik' => '1234567890123456', + 'sex' => 1, + 'config' => ['kode_desa' => '3301010001', 'nama_desa' => 'Desa API'], + ], + ], + ], + 'meta' => ['pagination' => ['total' => 1]], + ], 200), + ]); + + SettingAplikasi::updateOrCreate(['key' => 'api_server_database_gabungan'], ['value' => 'https://api.example.com']); + SettingAplikasi::updateOrCreate(['key' => 'api_key_database_gabungan'], ['value' => 'test-api-key']); + + $suplemen = Suplemen::create([ + 'nama' => 'Suplemen Gabungan', + 'slug' => 'suplemen-gabungan', + 'sasaran' => 1, + 'keterangan' => 'Test', + ]); + + SuplemenTerdata::create([ + 'suplemen_id' => $suplemen->id, + 'penduduk_id_gabungan' => 500, + 'desa_id' => '3301010001', + 'keterangan' => 'A1', + ]); + + $export = new ExportSuplemenTerdataGabungan($suplemen->id, [], [500]); + + $collection = $export->collection(); + + expect($collection)->toHaveCount(1) + ->and($collection->first()->penduduk_id_gabungan)->toBe(500) + ->and($collection->first()->desa_id)->toBe('3301010001') + ->and($collection->first()->penduduk)->not->toBeNull() + ->and($collection->first()->penduduk->nama)->toBe('Penduduk Dari API'); +}); + +test('export suplemen terdata excel calls gabungan api when gabungan active', function () { + SettingAplikasi::updateOrCreate(['key' => 'sinkronisasi_database_gabungan'], ['value' => '1']); + SettingAplikasi::updateOrCreate(['key' => 'api_server_database_gabungan'], ['value' => 'https://api.example.com']); + SettingAplikasi::updateOrCreate(['key' => 'api_key_database_gabungan'], ['value' => 'test-api-key']); + + Http::fake([ + 'https://api.example.com/api/v1/opendk/sync-penduduk-opendk*' => Http::response([ + 'data' => [ + [ + 'id' => 500, + 'attributes' => [ + 'nama' => 'Penduduk API Export', + 'nik' => '1234567890123456', + 'sex' => 1, + 'config' => ['kode_desa' => '3301010001', 'nama_desa' => 'Desa API'], + ], + ], + ], + 'meta' => ['pagination' => ['total' => 1]], + ], 200), + '*' => Http::response([ + 'data' => [], + 'meta' => ['pagination' => ['total' => 0]], + ], 200), + ]); + + $suplemen = Suplemen::create([ + 'nama' => 'Suplemen Gabungan Export', + 'slug' => 'suplemen-gabungan-export', + 'sasaran' => 1, + 'keterangan' => 'Test', + ]); + + SuplemenTerdata::create([ + 'suplemen_id' => $suplemen->id, + 'penduduk_id_gabungan' => 500, + 'desa_id' => '3301010001', + 'keterangan' => 'A1', + ]); + + $response = $this->get("/data/data-suplemen/export-terdata-excel/{$suplemen->id}"); + + $response->assertSuccessful(); + Http::assertSent(fn ($request) => str_contains($request->url(), 'api/v1/opendk/sync-penduduk-opendk')); +}); + test('export suplemen terdata by id with multiple terdata', function () { // Arrange: Buat data suplemen dengan multiple terdata Suplemen::query()->delete(); SuplemenTerdata::query()->delete(); - + $desa = DataDesa::factory()->create(); $penduduk1 = Penduduk::factory()->create(['desa_id' => $desa->desa_id, 'nama' => 'Penduduk 1']); $penduduk2 = Penduduk::factory()->create(['desa_id' => $desa->desa_id, 'nama' => 'Penduduk 2']); $penduduk3 = Penduduk::factory()->create(['desa_id' => $desa->desa_id, 'nama' => 'Penduduk 3']); - + $suplemen = Suplemen::create([ 'nama' => 'Test Suplemen Multiple', 'slug' => 'test-suplemen-multiple', 'sasaran' => 1, 'keterangan' => 'Test Keterangan' ]); - + SuplemenTerdata::create([ 'suplemen_id' => $suplemen->id, 'penduduk_id' => $penduduk1->id, From 783bd0e8115aeaf3ca3e389e06366de8dbbd6710 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 7 Sep 2026 13:34:47 +0700 Subject: [PATCH 11/52] perbaikan test --- app/Exports/ExportSuplemenTerdata.php | 9 +------ app/Exports/ExportSuplemenTerdataGabungan.php | 26 +++++++++++-------- .../Controllers/Data/SuplemenController.php | 4 +-- tests/Feature/SuplemenExportTest.php | 7 +++-- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/app/Exports/ExportSuplemenTerdata.php b/app/Exports/ExportSuplemenTerdata.php index d32b69a0ee..09e88c43f4 100644 --- a/app/Exports/ExportSuplemenTerdata.php +++ b/app/Exports/ExportSuplemenTerdata.php @@ -41,6 +41,7 @@ class ExportSuplemenTerdata implements FromCollection, WithHeadings, WithMapping, WithStyles { protected $suplemenId; + protected $filters; public function __construct($suplemenId = null, array $filters = []) @@ -77,9 +78,6 @@ public function collection() return $query->get(); } - /** - * @return array - */ public function headings(): array { return [ @@ -99,10 +97,6 @@ public function headings(): array ]; } - /** - * @param mixed $suplemenTerdata - * @return array - */ public function map($suplemenTerdata): array { $penduduk = $suplemenTerdata->penduduk; @@ -126,7 +120,6 @@ public function map($suplemenTerdata): array } /** - * @param Worksheet $sheet * @return array */ public function styles(Worksheet $sheet) diff --git a/app/Exports/ExportSuplemenTerdataGabungan.php b/app/Exports/ExportSuplemenTerdataGabungan.php index 03f11a1efc..a450bc6791 100644 --- a/app/Exports/ExportSuplemenTerdataGabungan.php +++ b/app/Exports/ExportSuplemenTerdataGabungan.php @@ -35,25 +35,24 @@ use App\Models\Penduduk; use App\Models\SuplemenTerdata; use App\Services\PendudukService; -use App\Support\Collection; +use Illuminate\Support\Collection; class ExportSuplemenTerdataGabungan extends ExportSuplemenTerdata { /** * @param array $pendudukGabunganIds */ - public function __construct($suplemenId = - null, array $filters = [], array $pendudukGabunganIds = []) + public function __construct($suplemenId = null, array $filters = [], array $pendudukGabunganIds = []) { - //parent::__construct($suplemenId, $filters); + parent::__construct($suplemenId, $filters); $this->pendudukGabunganIds = array_map('intval', $pendudukGabunganIds); } - public function collection() + public function collection(): Collection { - $collection = new Collection(); + $collection = parent::collection(); - if (empty($this->pendudukGabunganIds)) { + if ($collection->isEmpty() || empty($this->pendudukGabunganIds)) { return $collection; } @@ -116,9 +115,14 @@ private function mapGabunganPenduduk(array $item): Penduduk 'tanggal_lahir' => $attributes['tanggallahir'] ?? $attributes['tanggal_lahir'] ?? null, 'umur' => $attributes['umur'] ?? null, 'sex' => $attributes['sex'] ?? null, - 'alamat' => $attributes['alamat_wilayah'] ?? $attributes['alamat_wilayah'] ?? null, - ]); - $penduduk->setRelation('desa', new DataDesa(['nama' => $attributes['config']['nama_desa']])); + 'alamat' => $attributes['alamat_sekarang'] ?? $attributes['alamat'] ?? null, + ]); + + $penduduk->setRelation('desa', new DataDesa([ + 'nama' => data_get($attributes, 'config.nama_desa') ?? $attributes['nama_desa'] ?? null, + 'desa_id' => data_get($attributes, 'config.kode_desa') ?? $attributes['kode_desa'] ?? null, + ])); + return $penduduk; } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Data/SuplemenController.php b/app/Http/Controllers/Data/SuplemenController.php index 03af5cbe6f..2f8215c0dd 100644 --- a/app/Http/Controllers/Data/SuplemenController.php +++ b/app/Http/Controllers/Data/SuplemenController.php @@ -421,7 +421,7 @@ public function exportExcel(Request $request): BinaryFileResponse public function exportTerdataExcel(Request $request, int $id): BinaryFileResponse { $suplemen = Suplemen::findOrFail($id); - $filters = $request->only(['desa', 'nama_penduduk']); + $filters = $request->only(['desa', 'nama_penduduk']); $timestamp = date('Y-m-d-H-i-s'); $filename = "data-suplemen-terdata-{$suplemen->slug}-{$timestamp}.xlsx"; @@ -440,7 +440,7 @@ public function exportTerdataExcel(Request $request, int $id): BinaryFileRespons }); } - $pendudukGabunganIds = $query->whereNotNull('penduduk_id_gabungan')->pluck('penduduk_id_gabungan')->filter()->values()->all(); + $pendudukGabunganIds = $query->whereNotNull('penduduk_id_gabungan')->pluck('penduduk_id_gabungan')->filter()->values()->all(); return Excel::download(new ExportSuplemenTerdataGabungan($id, $filters, $pendudukGabunganIds), $filename); } diff --git a/tests/Feature/SuplemenExportTest.php b/tests/Feature/SuplemenExportTest.php index 7d5a555201..fc2854d7ab 100644 --- a/tests/Feature/SuplemenExportTest.php +++ b/tests/Feature/SuplemenExportTest.php @@ -5,9 +5,9 @@ use App\Exports\ExportSuplemenTerdataGabungan; use App\Models\DataDesa; use App\Models\Penduduk; +use App\Models\SettingAplikasi; use App\Models\Suplemen; use App\Models\SuplemenTerdata; -use App\Models\SettingAplikasi; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Http; use Maatwebsite\Excel\Facades\Excel; @@ -300,13 +300,16 @@ 'keterangan' => 'Test', ]); - SuplemenTerdata::create([ + $terdata = SuplemenTerdata::create([ 'suplemen_id' => $suplemen->id, 'penduduk_id_gabungan' => 500, 'desa_id' => '3301010001', 'keterangan' => 'A1', ]); + expect(SuplemenTerdata::count())->toBe(1) + ->and(SuplemenTerdata::first()->penduduk_id_gabungan)->toBe(500); + $export = new ExportSuplemenTerdataGabungan($suplemen->id, [], [500]); $collection = $export->collection(); From 0374b196d88f44af342f8571c83ce302b098501f Mon Sep 17 00:00:00 2001 From: Habibie Date: Mon, 7 Sep 2026 14:48:52 +0700 Subject: [PATCH 12/52] Perbaikan Tampilan Thumbnail Galeri, Pratinjau Gambar Dinamis --- .../Publikasi/GaleriController.php | 19 +- app/Http/Requests/GaleriRequest.php | 11 +- app/Models/Galeri.php | 19 +- app/Observers/GaleriObserver.php | 13 +- helpers/general_helper.php | 4 +- .../views/components/modal-image.blade.php | 81 +++++ .../views/publikasi/album/_form.blade.php | 2 +- .../views/publikasi/galeri/_form.blade.php | 141 +++++---- tests/Feature/GaleriControllerTest.php | 287 ++++++++++++++++++ 9 files changed, 504 insertions(+), 73 deletions(-) create mode 100644 resources/views/components/modal-image.blade.php create mode 100644 tests/Feature/GaleriControllerTest.php diff --git a/app/Http/Controllers/Publikasi/GaleriController.php b/app/Http/Controllers/Publikasi/GaleriController.php index 6599741916..ee099435d9 100644 --- a/app/Http/Controllers/Publikasi/GaleriController.php +++ b/app/Http/Controllers/Publikasi/GaleriController.php @@ -105,25 +105,28 @@ public function store(GaleriRequest $request) $imageNames = []; if ($request->hasFile('gambar')) { foreach ($request->file('gambar') as $file) { - $path = Storage::putFile('public/publikasi/galeri', $file); + $path = Storage::disk('public')->putFile('publikasi/galeri', $file); $imageNames[] = basename($path); } $input['gambar'] = $imageNames; + } else { + $input['gambar'] = null; } + $input['link'] = $input['jenis'] == 'file' ? null : ($input['link'] ?? null); $input['album_id'] = Session::get('album_id'); Galeri::create($input); - return redirect()->route('publikasi.galeri.index', Session::get('album_id'))->with('success', 'Album berhasil disimpan!'); + return redirect()->route('publikasi.galeri.index', Session::get('album_id'))->with('success', 'Galeri berhasil disimpan!'); } catch (\Exception $e) { Log::error('Galeri creation failed', [ 'error' => $e->getMessage(), 'user_id' => auth()->id(), ]); - return back()->withInput()->with('error', 'Simpan album gagal!'); + return back()->withInput()->with('error', 'Simpan galeri gagal!'); } } @@ -166,14 +169,18 @@ public function update(GaleriRequest $request, Galeri $galeri) $imageNames = []; if ($request->hasFile('gambar')) { foreach ($request->file('gambar') as $file) { - $path = Storage::putFile('public/publikasi/galeri', $file); + $path = Storage::disk('public')->putFile('publikasi/galeri', $file); $imageNames[] = basename($path); } $input['gambar'] = $imageNames; + } elseif (($input['jenis'] ?? null) == 'file') { + unset($input['gambar']); + } else { + $input['gambar'] = null; } - $input['link'] = $input['jenis'] == 'file' ? null : $input['link']; + $input['link'] = ($input['jenis'] ?? null) == 'file' ? null : ($input['link'] ?? null); $galeri->update($input); } catch (\Exception $e) { @@ -183,7 +190,7 @@ public function update(GaleriRequest $request, Galeri $galeri) 'galeri_id' => $galeri->id, ]); - return back()->withInput()->with('error', 'Galeri gagal dihapus!'); + return back()->withInput()->with('error', 'Galeri gagal diubah!'); } return redirect()->route('publikasi.galeri.index', Session::get('album_id'))->with('success', 'Galeri berhasil diubah!'); diff --git a/app/Http/Requests/GaleriRequest.php b/app/Http/Requests/GaleriRequest.php index bed893553a..430ce60bc9 100644 --- a/app/Http/Requests/GaleriRequest.php +++ b/app/Http/Requests/GaleriRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Services\FileUploadService; use Illuminate\Foundation\Http\FormRequest; class GaleriRequest extends FormRequest @@ -23,11 +24,15 @@ public function authorize() */ public function rules() { + $isUrl = $this->input('jenis') === 'url'; + $maxRule = FileUploadService::isLimitEnabled() ? '|max:1024' : ''; + return [ 'judul' => 'required|string|max:191', - 'jenis' => 'required', - 'link' => $this->input('jenis') == 'url' ? 'required|max:255' : 'nullable', - 'gambar.*' => $this->input('jenis') == 'url' ? 'nullable' : 'required|image|mimes:jpg,jpeg,png|max:1024', + 'jenis' => 'required|in:file,url', + 'link' => $isUrl ? 'required|string|max:255' : 'nullable', + 'gambar' => (! $isUrl && $this->isMethod('post')) ? 'required|array' : 'nullable|array', + 'gambar.*' => $isUrl ? 'nullable' : 'required|image|mimes:jpg,jpeg,png' . $maxRule . '|valid_file', 'status' => 'required', ]; } diff --git a/app/Models/Galeri.php b/app/Models/Galeri.php index 6189cf65d9..47e7a4a935 100644 --- a/app/Models/Galeri.php +++ b/app/Models/Galeri.php @@ -63,8 +63,21 @@ public function Album(): BelongsTo return $this->belongsTo(Album::class); } - protected function getGambarPathAttribute(){ - $gambar = $this->gambar[0]; - return $this->attributes['jenis'] == 'file' ? isThumbnail("publikasi/galeri/".$gambar) : asset('/img/no-image.png'); + public function getGambarPathAttribute(): string + { + if (($this->attributes['jenis'] ?? null) === 'file' && ! empty($this->gambar)) { + $gambar = is_array($this->gambar) ? ($this->gambar[0] ?? null) : $this->gambar; + if ($gambar) { + return isThumbnail('publikasi/galeri/' . $gambar); + } + } + + if (($this->attributes['jenis'] ?? null) === 'url' && ! empty($this->link)) { + if (preg_match('/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/', $this->link, $matches)) { + return 'https://img.youtube.com/vi/' . $matches[1] . '/hqdefault.jpg'; + } + } + + return asset('/img/no-image.png'); } } diff --git a/app/Observers/GaleriObserver.php b/app/Observers/GaleriObserver.php index 3509f8ddda..2892ff8fc9 100644 --- a/app/Observers/GaleriObserver.php +++ b/app/Observers/GaleriObserver.php @@ -30,15 +30,17 @@ public function updated(Galeri $galeri) if ($galeri->isDirty('gambar')) { // Ambil gambar lama dan baru dari database $oldImages = $galeri->getOriginal('gambar'); - $newImages = $galeri->gambar; + $newImages = $galeri->gambar ?? []; if (is_array($oldImages)) { // Cari gambar yang ada di array lama tapi tidak ada di array baru - $deletedImages = array_diff($oldImages, $newImages); + $deletedImages = array_diff($oldImages, is_array($newImages) ? $newImages : []); // Hapus setiap gambar yang sudah tidak ada di array baru foreach ($deletedImages as $image) { - Storage::disk('public')->delete($image); + if ($image) { + Storage::disk('public')->delete('publikasi/galeri/' . $image); + } } } } @@ -59,10 +61,9 @@ public function deleted(Galeri $galeri) if (is_array($images)) { foreach ($images as $image) { - // Cek jika file ada - if (Storage::disk('public')->exists($image)) { + if ($image && Storage::disk('public')->exists('publikasi/galeri/' . $image)) { // Hapus file gambar dari storage - Storage::disk('public')->delete($image); + Storage::disk('public')->delete('publikasi/galeri/' . $image); } } } diff --git a/helpers/general_helper.php b/helpers/general_helper.php index 8d6fc0f01d..2bacbf6e22 100644 --- a/helpers/general_helper.php +++ b/helpers/general_helper.php @@ -311,7 +311,9 @@ function is_wajib_ktp($umur, $status_kawin) function isThumbnail($url = null, $img = '/img/no-image.png') { - return ! empty($url) && Storage::disk('public')->exists($url) ? Storage::disk('public')->url($url) : asset($img); + $url = trim($url ?? ''); + + return ! empty($url) && Storage::disk('public')->exists($url) ? asset('storage/' . ltrim($url, '/')) : asset($img); } function is_img($url = null, $img = '/img/no-image.png') diff --git a/resources/views/components/modal-image.blade.php b/resources/views/components/modal-image.blade.php new file mode 100644 index 0000000000..6c2895063d --- /dev/null +++ b/resources/views/components/modal-image.blade.php @@ -0,0 +1,81 @@ +@props([ + 'id' => 'modal-image-preview', + 'title' => 'Pratinjau Gambar', +]) + + + +@once +@push('scripts') + +@endpush +@endonce diff --git a/resources/views/publikasi/album/_form.blade.php b/resources/views/publikasi/album/_form.blade.php index 964862c765..410b8d5e92 100644 --- a/resources/views/publikasi/album/_form.blade.php +++ b/resources/views/publikasi/album/_form.blade.php @@ -22,7 +22,7 @@
- gambar}" : null) }}" id="showthumbnail" style="width:100%; max-height:250px; float:left;" /> + gambar}" : null) }}" id="showthumbnail" style="width:100%; max-height:250px; float:left;" /> {!! html()->file('gambar')->class('form-control')->id('file-album')->attribute('accept', '.jpg,.jpeg,.png') !!} @if ($errors->has('gambar')) diff --git a/resources/views/publikasi/galeri/_form.blade.php b/resources/views/publikasi/galeri/_form.blade.php index 948b1d902d..736a6f6a50 100644 --- a/resources/views/publikasi/galeri/_form.blade.php +++ b/resources/views/publikasi/galeri/_form.blade.php @@ -2,7 +2,7 @@
@@ -31,28 +31,41 @@ @endif
- - -
- - @if (isset($galeri->gambar)) - @foreach ($galeri->gambar as $image) - - @endforeach - @endif -
- - @if (isset($galeri->gambar) == false) - + + + + +
+ + @if (isset($galeri) && !empty($galeri->gambar)) + @foreach ((array) $galeri->gambar as $image) + @php $imageUrl = asset('storage/publikasi/galeri/' . $image); @endphp +
+ + + +
+ {{ $image }}
+ + Pratinjau + + + Unduh + +
+
+ @endforeach + + Upload file baru di atas untuk menggantikan file yang ada. + @endif - {!! html()->file('gambar[]', [ - 'placeholder' => 'Thumbnail', - 'class' => 'form-control', - 'id' => 'file-album', - 'accept' => 'jpg,jpeg,png', - 'multiple' => true, - ]) !!} +
+
+
+ +
+
@if ($errors->has('gambar')) {{ $errors->first('gambar') }} @@ -66,7 +79,7 @@
- + {!! html()->select('status', ['0' => 'Tidak Aktif', '1' => 'Aktif'])->class('form-control')->value(old('status', isset($galeri) ? $galeri->status : '')) !!}
@@ -81,6 +94,14 @@ @push('scripts') @endpush + + diff --git a/tests/Feature/GaleriControllerTest.php b/tests/Feature/GaleriControllerTest.php new file mode 100644 index 0000000000..0ad0d7dee4 --- /dev/null +++ b/tests/Feature/GaleriControllerTest.php @@ -0,0 +1,287 @@ +withViewErrors([]); + $this->withoutMiddleware([ + Authenticate::class, + RoleMiddleware::class, + PermissionMiddleware::class, + CompleteProfile::class, + ]); +}); + +test('display the galeri index page', function () { + $album = Album::factory()->create(); + + $response = $this->get(route('publikasi.galeri.index', $album->id)); + + $response->assertStatus(200); + $response->assertViewIs('publikasi.galeri.index'); +}); + +test('display the galeri create page without broken image placeholder', function () { + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $response = $this->get(route('publikasi.galeri.create')); + + $response->assertStatus(200); + $response->assertViewIs('publikasi.galeri.create'); + // Ensure the old broken placeholder is not displayed + $response->assertDontSee('id="showthumbnail"', false); + $response->assertSee('id="file-galeri"', false); + $response->assertSee('id="preview-container"', false); +}); + +test('display the galeri edit page and render valid thumbnail path', function () { + Storage::fake('public'); + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + // Put a dummy image in storage + Storage::disk('public')->put('publikasi/galeri/sample.jpg', 'fake content'); + + $galeri = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Edit Test', + 'gambar' => ['sample.jpg'], + 'jenis' => 'file', + 'status' => true, + ]); + + $response = $this->get(route('publikasi.galeri.edit', $galeri->id)); + + $response->assertStatus(200); + $response->assertViewIs('publikasi.galeri.edit'); + // Should see sample.jpg in existing images + $response->assertSee('sample.jpg'); + // Should render modal image trigger and download button + $response->assertSee('data-toggle="modal-image"', false); + $response->assertSee('Unduh'); + $response->assertSee('id="modal-image-preview"', false); + $response->assertDontSee('publikasi/galeri/ sample.jpg'); + $response->assertDontSee('isThumbnail(" publikasi/galeri/'); +}); + +test('create a galeri with file upload', function () { + Storage::fake('public'); + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $file = UploadedFile::fake()->image('foto1.jpg', 600, 400); + + $data = [ + 'judul' => 'Galeri Foto Kegiatan', + 'jenis' => 'file', + 'status' => '1', + 'gambar' => [$file], + ]; + + $response = $this->post(route('publikasi.galeri.store'), $data); + + $response->assertRedirect(route('publikasi.galeri.index', $album->id)); + $response->assertSessionHas('success', 'Galeri berhasil disimpan!'); + + $galeri = Galeri::where('judul', 'Galeri Foto Kegiatan')->first(); + expect($galeri)->not->toBeNull(); + expect($galeri->jenis)->toBe('file'); + expect($galeri->gambar)->toBeArray()->toHaveCount(1); + + Storage::disk('public')->assertExists('publikasi/galeri/' . $galeri->gambar[0]); +}); + +test('create a galeri with url link', function () { + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $data = [ + 'judul' => 'Galeri Video YouTube', + 'jenis' => 'url', + 'link' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'status' => '1', + ]; + + $response = $this->post(route('publikasi.galeri.store'), $data); + + $response->assertRedirect(route('publikasi.galeri.index', $album->id)); + $response->assertSessionHas('success', 'Galeri berhasil disimpan!'); + + $galeri = Galeri::where('judul', 'Galeri Video YouTube')->first(); + expect($galeri)->not->toBeNull(); + expect($galeri->jenis)->toBe('url'); + expect($galeri->link)->toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + expect($galeri->gambar)->toBeNull(); +}); + +test('update a galeri without changing image preserves existing image', function () { + Storage::fake('public'); + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $galeri = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Awal', + 'gambar' => ['existing_image.jpg'], + 'jenis' => 'file', + 'status' => true, + ]); + + $data = [ + 'judul' => 'Galeri Diperbarui', + 'jenis' => 'file', + 'status' => '1', + ]; + + $response = $this->put(route('publikasi.galeri.update', $galeri->id), $data); + + $response->assertRedirect(route('publikasi.galeri.index', $album->id)); + $response->assertSessionHas('success', 'Galeri berhasil diubah!'); + + $galeri->refresh(); + expect($galeri->judul)->toBe('Galeri Diperbarui'); + expect($galeri->gambar)->toBe(['existing_image.jpg']); +}); + +test('update a galeri with new image replaces old image in storage', function () { + Storage::fake('public'); + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + Storage::disk('public')->put('publikasi/galeri/old_file.jpg', 'old content'); + + $galeri = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri File Lama', + 'gambar' => ['old_file.jpg'], + 'jenis' => 'file', + 'status' => true, + ]); + + $newFile = UploadedFile::fake()->image('new_file.jpg', 600, 400); + + $data = [ + 'judul' => 'Galeri File Baru', + 'jenis' => 'file', + 'status' => '1', + 'gambar' => [$newFile], + ]; + + $response = $this->put(route('publikasi.galeri.update', $galeri->id), $data); + + $response->assertRedirect(route('publikasi.galeri.index', $album->id)); + $response->assertSessionHas('success', 'Galeri berhasil diubah!'); + + $galeri->refresh(); + expect($galeri->judul)->toBe('Galeri File Baru'); + expect($galeri->gambar[0])->not->toBe('old_file.jpg'); + + // Observer should have deleted the old file + Storage::disk('public')->assertMissing('publikasi/galeri/old_file.jpg'); + // And new file should exist + Storage::disk('public')->assertExists('publikasi/galeri/' . $galeri->gambar[0]); +}); + +test('delete a galeri removes image from storage', function () { + Storage::fake('public'); + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + Storage::disk('public')->put('publikasi/galeri/to_delete.jpg', 'content'); + + $galeri = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Hapus', + 'gambar' => ['to_delete.jpg'], + 'jenis' => 'file', + 'status' => true, + ]); + + $response = $this->delete(route('publikasi.galeri.destroy', $galeri->id)); + + $response->assertRedirect(route('publikasi.galeri.index', $album->id)); + $response->assertSessionHas('success', 'Galeri sukses dihapus!'); + + $this->assertDatabaseMissing('galeris', ['id' => $galeri->id]); + Storage::disk('public')->assertMissing('publikasi/galeri/to_delete.jpg'); +}); + +test('toggle galeri status', function () { + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $galeri = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Status', + 'jenis' => 'file', + 'status' => false, + ]); + + $response = $this->put(route('publikasi.galeri.status', $galeri->id)); + + $galeri->refresh(); + expect($galeri->status)->toBeTrue(); + $response->assertRedirect(route('publikasi.galeri.index', $album->id)); +}); + +test('getGambarPathAttribute handles file, youtube url, and empty safely', function () { + Storage::fake('public'); + $album = Album::factory()->create(); + + // 1. File that exists + Storage::disk('public')->put('publikasi/galeri/ada.jpg', 'fake content'); + $galeri1 = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Ada File', + 'gambar' => ['ada.jpg'], + 'jenis' => 'file', + 'status' => true, + ]); + expect($galeri1->gambar_path)->toContain('publikasi/galeri/ada.jpg'); + + // 2. File that does not exist in storage -> returns fallback no-image + $galeri2 = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Tak Ada File', + 'gambar' => ['tidak_ada.jpg'], + 'jenis' => 'file', + 'status' => true, + ]); + expect($galeri2->gambar_path)->toContain('no-image.png'); + + // 3. YouTube link -> returns YouTube thumbnail url + $galeri3 = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri YouTube', + 'link' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'jenis' => 'url', + 'status' => true, + ]); + expect($galeri3->gambar_path)->toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg'); + + // 4. Non-youtube link -> returns fallback no-image + $galeri4 = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Web Biasa', + 'link' => 'https://opendesa.id', + 'jenis' => 'url', + 'status' => true, + ]); + expect($galeri4->gambar_path)->toContain('no-image.png'); + + // 5. Empty / null fields -> does not crash with undefined array key + $galeri5 = new Galeri(); + expect($galeri5->gambar_path)->toContain('no-image.png'); +}); From 856a60ffcc6363c1875a5e4cf6eb9ed9b940927f Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 7 Sep 2026 15:35:11 +0700 Subject: [PATCH 13/52] perbaikan filter kesehatan --- app/Exports/ExportAKIAKB.php | 2 +- app/Exports/ExportEpidemiPenyakit.php | 2 +- app/Exports/ExportFasilitasPaud.php | 2 +- app/Exports/ExportImunisasi.php | 2 +- app/Exports/ExportPutusSekolah.php | 2 +- app/Exports/ExportTingkatPendidikan.php | 2 +- app/Exports/ExportToiletSanitasi.php | 2 +- .../Controllers/Data/AKIAKBController.php | 7 ++- .../Data/FasilitasPaudController.php | 24 +++++----- .../Controllers/Data/ImunisasiController.php | 6 ++- .../Data/PutusSekolahController.php | 31 ++++++------- .../Data/TingkatPendidikanController.php | 6 ++- .../Data/ToiletSanitasiController.php | 20 ++++----- app/Http/Requests/UpdateAKIAKBRequest.php | 29 ++++++++++++ .../Requests/UpdateFasilitasPaudRequest.php | 35 +++++++++++++++ .../Requests/UpdatePutusSekolahRequest.php | 45 +++++++++++++++++++ .../Requests/UpdateToiletSanitasiRequest.php | 29 ++++++++++++ helpers/general_helper.php | 14 +++++- resources/views/data/aki_akb/index.blade.php | 10 +++-- .../views/data/anggaran_desa/index.blade.php | 5 ++- .../views/data/data_sarana/index.blade.php | 7 ++- .../data/epidemi_penyakit/index.blade.php | 7 ++- .../views/data/fasilitas_paud/edit.blade.php | 2 +- .../data/fasilitas_paud/import.blade.php | 8 +--- .../views/data/fasilitas_paud/index.blade.php | 7 ++- .../views/data/imunisasi/form_edit.blade.php | 6 +-- .../views/data/imunisasi/index.blade.php | 7 ++- resources/views/data/keluarga/index.blade.php | 5 ++- .../views/data/laporan-apbdes/index.blade.php | 5 ++- .../data/laporan-penduduk/index.blade.php | 5 ++- .../views/data/pembangunan/index.blade.php | 5 ++- resources/views/data/penduduk/index.blade.php | 5 ++- .../data/program_bantuan/index.blade.php | 5 ++- .../views/data/putus_sekolah/import.blade.php | 8 +--- .../views/data/putus_sekolah/index.blade.php | 7 ++- .../data/tingkat_pendidikan/import.blade.php | 10 ++--- .../data/tingkat_pendidikan/index.blade.php | 7 ++- .../data/toilet_sanitasi/form_edit.blade.php | 8 ++-- .../data/toilet_sanitasi/import.blade.php | 2 +- .../data/toilet_sanitasi/index.blade.php | 7 ++- 40 files changed, 295 insertions(+), 103 deletions(-) create mode 100644 app/Http/Requests/UpdateAKIAKBRequest.php create mode 100644 app/Http/Requests/UpdateFasilitasPaudRequest.php create mode 100644 app/Http/Requests/UpdatePutusSekolahRequest.php create mode 100644 app/Http/Requests/UpdateToiletSanitasiRequest.php diff --git a/app/Exports/ExportAKIAKB.php b/app/Exports/ExportAKIAKB.php index 92c9e66cc7..6c9e3d63d3 100644 --- a/app/Exports/ExportAKIAKB.php +++ b/app/Exports/ExportAKIAKB.php @@ -109,7 +109,7 @@ public function map($akiAkb): array return [ $akiAkb->id, - $akiAkb->desa->nama ?? 'Tidak Diketahui', + nama_desa($akiAkb->desa_id) ?? 'Tidak Diketahui', $akiAkb->desa_id, $akiAkb->aki, $akiAkb->akb, diff --git a/app/Exports/ExportEpidemiPenyakit.php b/app/Exports/ExportEpidemiPenyakit.php index ed09eeb4d5..567decd026 100644 --- a/app/Exports/ExportEpidemiPenyakit.php +++ b/app/Exports/ExportEpidemiPenyakit.php @@ -80,7 +80,7 @@ public function map($epidemiPenyakit): array { return [ $epidemiPenyakit->id, - $epidemiPenyakit->desa->nama ?? '', + nama_desa($epidemiPenyakit->desa_id) ?? '', $epidemiPenyakit->penyakit->nama ?? '', $epidemiPenyakit->jumlah_penderita, months_list()[$epidemiPenyakit->bulan] ?? $epidemiPenyakit->bulan, diff --git a/app/Exports/ExportFasilitasPaud.php b/app/Exports/ExportFasilitasPaud.php index 859e9e4abb..198e0a4310 100644 --- a/app/Exports/ExportFasilitasPaud.php +++ b/app/Exports/ExportFasilitasPaud.php @@ -81,7 +81,7 @@ public function map($fasilitasPaud): array { return [ $fasilitasPaud->id, - $fasilitasPaud->desa->nama ?? '', + nama_desa($fasilitasPaud->desa_id) ?? '', $fasilitasPaud->jumlah_paud, $fasilitasPaud->jumlah_guru_paud, $fasilitasPaud->jumlah_siswa_paud, diff --git a/app/Exports/ExportImunisasi.php b/app/Exports/ExportImunisasi.php index 7417df3fc1..60437ce14e 100644 --- a/app/Exports/ExportImunisasi.php +++ b/app/Exports/ExportImunisasi.php @@ -82,7 +82,7 @@ public function map($imunisasi): array return [ $imunisasi->id, - $imunisasi->desa->nama ?? 'Tidak Diketahui', + nama_desa($imunisasi->desa_id) ?? 'Tidak Diketahui', $imunisasi->desa_id, $imunisasi->cakupan_imunisasi, // Format dengan persen $bulanList[$imunisasi->bulan] ?? 'Tidak Diketahui', diff --git a/app/Exports/ExportPutusSekolah.php b/app/Exports/ExportPutusSekolah.php index df09a7115a..a83d7fcf91 100644 --- a/app/Exports/ExportPutusSekolah.php +++ b/app/Exports/ExportPutusSekolah.php @@ -86,7 +86,7 @@ public function map($putusSekolah): array { return [ $putusSekolah->id, - $putusSekolah->desa->nama ?? '', + nama_desa($putusSekolah->desa_id) ?? '', $putusSekolah->siswa_paud, $putusSekolah->anak_usia_paud, $putusSekolah->siswa_sd, diff --git a/app/Exports/ExportTingkatPendidikan.php b/app/Exports/ExportTingkatPendidikan.php index bd264584b4..aa938de29c 100644 --- a/app/Exports/ExportTingkatPendidikan.php +++ b/app/Exports/ExportTingkatPendidikan.php @@ -83,7 +83,7 @@ public function map($tingkatPendidikan): array { return [ $tingkatPendidikan->id, - $tingkatPendidikan->desa->nama ?? '', + nama_desa($tingkatPendidikan->desa_id) ?? '', $tingkatPendidikan->tidak_tamat_sekolah, $tingkatPendidikan->tamat_sd, $tingkatPendidikan->tamat_smp, diff --git a/app/Exports/ExportToiletSanitasi.php b/app/Exports/ExportToiletSanitasi.php index 1122e105b4..081e26bdd5 100644 --- a/app/Exports/ExportToiletSanitasi.php +++ b/app/Exports/ExportToiletSanitasi.php @@ -83,7 +83,7 @@ public function map($toiletSanitasi): array return [ $toiletSanitasi->id, - $toiletSanitasi->desa->nama ?? 'Tidak Diketahui', + nama_desa($toiletSanitasi->desa_id) ?? 'Tidak Diketahui', $toiletSanitasi->desa_id, $toiletSanitasi->toilet, $toiletSanitasi->sanitasi, diff --git a/app/Http/Controllers/Data/AKIAKBController.php b/app/Http/Controllers/Data/AKIAKBController.php index 2553c1f004..b7ceecea61 100644 --- a/app/Http/Controllers/Data/AKIAKBController.php +++ b/app/Http/Controllers/Data/AKIAKBController.php @@ -34,6 +34,7 @@ use App\Exports\ExportAKIAKB; use App\Http\Controllers\Controller; use App\Http\Requests\ImportAKIAKBRequest; +use App\Http\Requests\UpdateAKIAKBRequest; use App\Imports\ImporAKIAKB; use App\Models\AkiAkb; use App\Services\DesaService; @@ -65,8 +66,12 @@ public function index() */ public function getDataAKIAKB() { + $desa = request()->input('desa'); $listDesa = (new DesaService)->listDesa()->pluck('nama', 'desa_id'); - return DataTables::of(AkiAkb::with(['desa'])->get()) + return DataTables::of(AkiAkb::when($desa && $desa !== 'Semua', function ($query) use ($desa) { + return $query->where('desa_id', $desa); + }) + ->with(['desa'])->get()) ->addColumn('aksi', function ($row) { $data['edit_url'] = auth()->user()->can('access.data.aki_akb.edit') ? route('data.aki-akb.edit', $row->id) : null; $data['delete_url'] = auth()->user()->can('access.data.aki_akb.delete') ? route('data.aki-akb.destroy', $row->id) : null; diff --git a/app/Http/Controllers/Data/FasilitasPaudController.php b/app/Http/Controllers/Data/FasilitasPaudController.php index 03e6122b4d..baeaa52bcb 100644 --- a/app/Http/Controllers/Data/FasilitasPaudController.php +++ b/app/Http/Controllers/Data/FasilitasPaudController.php @@ -34,8 +34,10 @@ use App\Exports\ExportFasilitasPaud; use App\Http\Controllers\Controller; use App\Http\Requests\ImportFasilitasPaudRequest; +use App\Http\Requests\UpdateFasilitasPaudRequest; use App\Imports\ImporFasilitasPaud; use App\Models\FasilitasPAUD; +use App\Services\DesaService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; @@ -59,12 +61,18 @@ public function index() */ public function getDataFasilitasPAUD() { - return DataTables::of(FasilitasPAUD::with(['desa'])->get()) + $desa = request()->input('desa'); + $listDesa = (new DesaService)->listDesa()->pluck('nama', 'desa_id'); + return DataTables::of(FasilitasPAUD::when($desa && $desa !== 'Semua', function ($query) use ($desa) { + return $query->where('desa_id', $desa); + })->with(['desa'])->get()) ->addColumn('aksi', function ($row) { $data['edit_url'] = auth()->user()->can('access.data.fasilitas_paud.edit') ? route('data.fasilitas-paud.edit', $row->id) : null; $data['delete_url'] = auth()->user()->can('access.data.fasilitas_paud.delete') ? route('data.fasilitas-paud.destroy', $row->id) : null; return view('forms.aksi', $data); + })->addColumn('nama_desa', function ($row) use ($listDesa) { + return $row->desa->nama ?? $listDesa[$row->desa_id] ?? '-'; }) ->rawColumns(['aksi'])->make(); } @@ -116,7 +124,7 @@ public function edit($id) { $fasilitas = FasilitasPAUD::with(['desa'])->findOrFail($id); $page_title = 'Fasilitas PAUD'; - $page_description = 'Ubah Fasilitas PAUD : Desa ' . $fasilitas->desa->nama; + $page_description = 'Ubah Fasilitas PAUD : Desa ' . nama_desa($fasilitas->desa_id); return view('data.fasilitas_paud.edit', compact('page_title', 'page_description', 'fasilitas')); } @@ -127,18 +135,10 @@ public function edit($id) * @param int $id * @return Response */ - public function update(Request $request, $id) + public function update(UpdateFasilitasPaudRequest $request, $id) { - request()->validate([ - 'jumlaah_paud' => 'required', - 'jumlah_guru_paud' => 'required', - 'jumlah_siswa_paud' => 'required', - 'semester' => 'required', - 'tahun' => 'required', - ]); - try { - FasilitasPAUD::findOrFail($id)->update($request->all()); + FasilitasPAUD::findOrFail($id)->update($request->validated()); } catch (\Exception $e) { Log::error('Fasilitas PAUD update failed', [ 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Data/ImunisasiController.php b/app/Http/Controllers/Data/ImunisasiController.php index ac5a550cca..fef53f2ec0 100644 --- a/app/Http/Controllers/Data/ImunisasiController.php +++ b/app/Http/Controllers/Data/ImunisasiController.php @@ -65,8 +65,12 @@ public function index() */ public function getDataAKIAKB() { + $desa = request()->input('desa'); $listDesa = (new DesaService)->listDesa()->pluck('nama', 'desa_id'); - return DataTables::of(Imunisasi::with(['desa'])->get()) + return DataTables::of(Imunisasi::when($desa && $desa !== 'Semua', function ($query) use ($desa) { + return $query->where('desa_id', $desa); + }) + ->with(['desa'])->get()) ->addColumn('aksi', function ($row) { $data['edit_url'] = auth()->user()->can('access.data.imunisasi.edit') ? route('data.imunisasi.edit', $row->id) : null; $data['delete_url'] = auth()->user()->can('access.data.imunisasi.delete') ? route('data.imunisasi.destroy', $row->id) : null; diff --git a/app/Http/Controllers/Data/PutusSekolahController.php b/app/Http/Controllers/Data/PutusSekolahController.php index 0273a00616..78d50ba574 100644 --- a/app/Http/Controllers/Data/PutusSekolahController.php +++ b/app/Http/Controllers/Data/PutusSekolahController.php @@ -34,8 +34,10 @@ use App\Exports\ExportPutusSekolah; use App\Http\Controllers\Controller; use App\Http\Requests\ImportPutusSekolahRequest; +use App\Http\Requests\UpdatePutusSekolahRequest; use App\Imports\ImporPutusSekolah; use App\Models\PutusSekolah; +use App\Services\DesaService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; @@ -59,12 +61,18 @@ public function index() */ public function getDataPutusSekolah() { - return DataTables::of(PutusSekolah::with(['desa'])->get()) - ->addColumn('aksi', function ($row) { + $desa = request()->input('desa'); + $listDesa = (new DesaService)->listDesa()->pluck('nama', 'desa_id'); + return DataTables::of(PutusSekolah::when($desa && $desa !== 'Semua', function ($query) use ($desa) { + return $query->where('desa_id', $desa); + })->with(['desa'])->get()) + ->addColumn('aksi', function ($row) { $data['edit_url'] = auth()->user()->can('access.data.putus_sekolah.edit') ? route('data.putus-sekolah.edit', $row->id) : null; $data['delete_url'] = auth()->user()->can('access.data.putus_sekolah.delete') ? route('data.putus-sekolah.destroy', $row->id) : null; return view('forms.aksi', $data); + })->addColumn('nama_desa', function ($row) use ($listDesa) { + return $row->desa->nama ?? $listDesa[$row->desa_id] ?? '-'; }) ->rawColumns(['aksi'])->make(); } @@ -116,7 +124,7 @@ public function edit($id) { $siswa = PutusSekolah::with(['desa'])->findOrFail($id); $page_title = 'Siswa Putus Sekolah'; - $page_description = 'Ubah Siswa Putus Sekolah : Desa ' . $siswa->desa->nama; + $page_description = 'Ubah Siswa Putus Sekolah : Desa ' . nama_desa($siswa->desa_id); return view('data.putus_sekolah.edit', compact('page_title', 'page_description', 'siswa')); } @@ -127,23 +135,10 @@ public function edit($id) * @param int $id * @return Response */ - public function update(Request $request, $id) + public function update(UpdatePutusSekolahRequest $request, $id) { - request()->validate([ - 'siswa_paud' => 'required', - 'anak_usia_paud' => 'required', - 'siswa_sd' => 'required', - 'anak_usia_sd' => 'required', - 'siswa_smp' => 'required', - 'anak_usia_smp' => 'required', - 'siswa_sma' => 'required', - 'anak_usia_sma' => 'required', - 'semester' => 'required', - 'tahun' => 'required', - ]); - try { - PutusSekolah::findOrFail($id)->update($request->all()); + PutusSekolah::findOrFail($id)->update($request->validated()); } catch (\Exception $e) { Log::error('Putus Sekolah update failed', [ 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Data/TingkatPendidikanController.php b/app/Http/Controllers/Data/TingkatPendidikanController.php index 2fdab92d08..8f27d095c1 100644 --- a/app/Http/Controllers/Data/TingkatPendidikanController.php +++ b/app/Http/Controllers/Data/TingkatPendidikanController.php @@ -36,6 +36,7 @@ use App\Http\Requests\TingkatPendidikanRequest; use App\Imports\ImporTingkatPendidikan; use App\Models\TingkatPendidikan; +use App\Services\DesaService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; @@ -61,7 +62,7 @@ public function getData() { if (request()->ajax()) { $desa = request()->input('desa'); - + $listDesa = (new DesaService)->listDesa()->pluck('nama', 'desa_id'); return DataTables::of( TingkatPendidikan::when($desa && $desa !== 'Semua', function ($query) use ($desa) { return $query->where('desa_id', $desa); @@ -74,6 +75,9 @@ public function getData() return view('forms.aksi', $data); }) + ->addColumn('nama_desa', function ($row) use ($listDesa) { + return $row->desa->nama ?? $listDesa[$row->desa_id] ?? '-'; + }) ->rawColumns(['aksi']) ->make(); } diff --git a/app/Http/Controllers/Data/ToiletSanitasiController.php b/app/Http/Controllers/Data/ToiletSanitasiController.php index aa60afe6e3..227f6eae17 100644 --- a/app/Http/Controllers/Data/ToiletSanitasiController.php +++ b/app/Http/Controllers/Data/ToiletSanitasiController.php @@ -34,6 +34,7 @@ use App\Exports\ExportToiletSanitasi; use App\Http\Controllers\Controller; use App\Http\Requests\ImportToiletSanitasiRequest; +use App\Http\Requests\UpdateToiletSanitasiRequest; use App\Imports\ImporToiletSanitasi; use App\Models\ToiletSanitasi; use App\Services\DesaService; @@ -65,8 +66,12 @@ public function index() */ public function getDataAKIAKB() { + $desa = request()->input('desa'); $listDesa = (new DesaService)->listDesa()->pluck('nama', 'desa_id'); - return DataTables::of(ToiletSanitasi::with(['desa'])) + return DataTables::of(ToiletSanitasi::when($desa && $desa !== 'Semua', function ($query) use ($desa) { + return $query->where('desa_id', $desa); + }) + ->with(['desa'])) ->addColumn('aksi', function ($row) { $data['edit_url'] = auth()->user()->can('access.data.toilet_sanitasi.edit') ? route('data.toilet-sanitasi.edit', $row->id) : null; $data['delete_url'] = auth()->user()->can('access.data.toilet_sanitasi.delete') ? route('data.toilet-sanitasi.destroy', $row->id) : null; @@ -114,7 +119,7 @@ public function do_import(ImportToiletSanitasiRequest $request) return back()->with('error', 'Import data gagal.'); } - return back()->with('success', 'Import data sukses.'); + return redirect()->route('data.toilet_sanitasi.index')->with('success', 'Data berhasil diimpor!'); } /** @@ -127,7 +132,7 @@ public function edit($id) { $toilet = ToiletSanitasi::with(['desa'])->findOrFail($id); $page_title = 'Toilet & Sanitasi'; - $page_description = 'Ubah Toilet & Sanitasi : ' . $toilet->desa->nama; + $page_description = 'Ubah Toilet & Sanitasi : ' . nama_desa($toilet->desa_id); return view('data.toilet_sanitasi.edit', compact('page_title', 'page_description', 'toilet')); } @@ -138,15 +143,10 @@ public function edit($id) * @param int $id * @return Response */ - public function update(Request $request, $id) + public function update(UpdateToiletSanitasiRequest $request, $id) { - request()->validate([ - 'toilet' => 'required', - 'sanitasi' => 'required', - ]); - try { - ToiletSanitasi::findOrFail($id)->update($request->all()); + ToiletSanitasi::findOrFail($id)->update($request->validated()); } catch (\Exception $e) { Log::error('Toilet Sanitasi update failed', [ 'error' => $e->getMessage(), diff --git a/app/Http/Requests/UpdateAKIAKBRequest.php b/app/Http/Requests/UpdateAKIAKBRequest.php new file mode 100644 index 0000000000..a5fb7cb4ec --- /dev/null +++ b/app/Http/Requests/UpdateAKIAKBRequest.php @@ -0,0 +1,29 @@ + 'required|integer', + 'akb' => 'required|integer', + ]; + } + + public function messages() + { + return [ + 'aki.required' => 'Jumlah AKI wajib diisi.', + 'akb.required' => 'Jumlah AKB wajib diisi.', + ]; + } +} diff --git a/app/Http/Requests/UpdateFasilitasPaudRequest.php b/app/Http/Requests/UpdateFasilitasPaudRequest.php new file mode 100644 index 0000000000..d7fd87a0bb --- /dev/null +++ b/app/Http/Requests/UpdateFasilitasPaudRequest.php @@ -0,0 +1,35 @@ + 'required|integer', + 'jumlah_guru_paud' => 'required|integer', + 'jumlah_siswa_paud' => 'required|integer', + 'semester' => 'required|integer', + 'tahun' => 'required|integer', + ]; + } + + public function messages() + { + return [ + 'jumlah_paud.required' => 'Jumlah PAUD wajib diisi.', + 'jumlah_guru_paud.required' => 'Jumlah guru PAUD wajib diisi.', + 'jumlah_siswa_paud.required' => 'Jumlah siswa PAUD wajib diisi.', + 'semester.required' => 'Semester wajib diisi.', + 'tahun.required' => 'Tahun wajib diisi.', + ]; + } +} diff --git a/app/Http/Requests/UpdatePutusSekolahRequest.php b/app/Http/Requests/UpdatePutusSekolahRequest.php new file mode 100644 index 0000000000..eed6bca7ca --- /dev/null +++ b/app/Http/Requests/UpdatePutusSekolahRequest.php @@ -0,0 +1,45 @@ + 'required|integer', + 'anak_usia_paud' => 'required|integer', + 'siswa_sd' => 'required|integer', + 'anak_usia_sd' => 'required|integer', + 'siswa_smp' => 'required|integer', + 'anak_usia_smp' => 'required|integer', + 'siswa_sma' => 'required|integer', + 'anak_usia_sma' => 'required|integer', + 'semester' => 'required|integer', + 'tahun' => 'required|integer', + ]; + } + + public function messages() + { + return [ + 'siswa_paud.required' => 'Jumlah siswa PAUD wajib diisi.', + 'anak_usia_paud.required' => 'Jumlah anak usia PAUD wajib diisi.', + 'siswa_sd.required' => 'Jumlah siswa SD wajib diisi.', + 'anak_usia_sd.required' => 'Jumlah anak usia SD wajib diisi.', + 'siswa_smp.required' => 'Jumlah siswa SMP wajib diisi.', + 'anak_usia_smp.required' => 'Jumlah anak usia SMP wajib diisi.', + 'siswa_sma.required' => 'Jumlah siswa SMA wajib diisi.', + 'anak_usia_sma.required' => 'Jumlah anak usia SMA wajib diisi.', + 'semester.required' => 'Semester wajib diisi.', + 'tahun.required' => 'Tahun wajib diisi.', + ]; + } +} diff --git a/app/Http/Requests/UpdateToiletSanitasiRequest.php b/app/Http/Requests/UpdateToiletSanitasiRequest.php new file mode 100644 index 0000000000..545b15a8dc --- /dev/null +++ b/app/Http/Requests/UpdateToiletSanitasiRequest.php @@ -0,0 +1,29 @@ + 'required|integer', + 'sanitasi' => 'required|integer', + ]; + } + + public function messages() + { + return [ + 'toilet.required' => 'Jumlah toilet wajib diisi.', + 'sanitasi.required' => 'Jumlah sanitasi wajib diisi.', + ]; + } +} diff --git a/helpers/general_helper.php b/helpers/general_helper.php index 8d6fc0f01d..2a67b5c956 100644 --- a/helpers/general_helper.php +++ b/helpers/general_helper.php @@ -748,7 +748,19 @@ function getBulan(int $bln) return $bulan[$bln]; } -function kembalikanSlug($str): ?string +if (! function_exists('list_desa')) { + function list_desa(bool $all = false) + { + return (new \App\Services\DesaService)->listDesa($all)->pluck('nama', 'desa_id'); + } +} + +if (! function_exists('nama_desa')) { + function nama_desa(string $desaId): ?string + { + return list_desa()[$desaId] ?? null; + } +} { // Ganti '-' dengan spasi dan hilangkan titik '.' return str_replace(['-', '.'], [' ', ''], $str); diff --git a/resources/views/data/aki_akb/index.blade.php b/resources/views/data/aki_akb/index.blade.php index 1b3f012ee2..f6ed834ed4 100644 --- a/resources/views/data/aki_akb/index.blade.php +++ b/resources/views/data/aki_akb/index.blade.php @@ -55,7 +55,10 @@ url: "{!! route('data.aki-akb.getdata') !!}", type: 'POST', data: function(d) { - d.desa_id = $('#list_desa').val(); + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } } }, columns: [{ @@ -92,10 +95,11 @@ class: 'text-center', [1, 'desc'] ] }); - + $('#list_desa').on('select2:select', function(e) { - data.columns(1).search(this.value).draw(); + data.ajax.reload(); }); + }); @include('forms.datatable-vertical') diff --git a/resources/views/data/anggaran_desa/index.blade.php b/resources/views/data/anggaran_desa/index.blade.php index 3a445b4c44..38467f4526 100644 --- a/resources/views/data/anggaran_desa/index.blade.php +++ b/resources/views/data/anggaran_desa/index.blade.php @@ -55,7 +55,10 @@ url: "{!! route('data.anggaran-desa.getdata') !!}", type: "POST", data: function(d) { - d.desa = $('#list_desa').val(); + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } } }, columns: [{ diff --git a/resources/views/data/data_sarana/index.blade.php b/resources/views/data/data_sarana/index.blade.php index 20f38c3e07..c8b620e7b8 100644 --- a/resources/views/data/data_sarana/index.blade.php +++ b/resources/views/data/data_sarana/index.blade.php @@ -76,8 +76,11 @@ url: "{{ route('data.data-sarana.getdata') }}", type: "POST", data: function(d) { - d.desa_id = $('#list_desa').val(), - d.kategori = $('#kategori').val() + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } + d.kategori = $('#kategori').val() } }, columns: [{ diff --git a/resources/views/data/epidemi_penyakit/index.blade.php b/resources/views/data/epidemi_penyakit/index.blade.php index 85530fa9a9..f2a34390bb 100644 --- a/resources/views/data/epidemi_penyakit/index.blade.php +++ b/resources/views/data/epidemi_penyakit/index.blade.php @@ -62,7 +62,10 @@ url: "{!! route('data.epidemi-penyakit.getdata') !!}", type: 'POST', data: function(d) { - d.desa = $('#list_desa').val(); + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } } }, columns: [{ @@ -99,7 +102,7 @@ class: 'text-center', }); $('#list_desa').on('select2:select', function(e) { - data.columns(1).search(this.value).draw(); + data.ajax.reload(); }); }); diff --git a/resources/views/data/fasilitas_paud/edit.blade.php b/resources/views/data/fasilitas_paud/edit.blade.php index 1a34d1dcfb..c088134a54 100644 --- a/resources/views/data/fasilitas_paud/edit.blade.php +++ b/resources/views/data/fasilitas_paud/edit.blade.php @@ -29,7 +29,7 @@ @endif - {!! html()->form()->open() !!} + {!! html()->form('PUT', route('data.fasilitas-paud.update', $fasilitas->id))->class('form-horizontal form-label-left')->open() !!}
diff --git a/resources/views/data/fasilitas_paud/import.blade.php b/resources/views/data/fasilitas_paud/import.blade.php index 10ecfde187..1d9d24f9da 100644 --- a/resources/views/data/fasilitas_paud/import.blade.php +++ b/resources/views/data/fasilitas_paud/import.blade.php @@ -43,11 +43,7 @@
- + @include('layouts.fragments.select-desa', ['selectAttributes' => ['name' => 'desa_id','data-width' => '100%', 'required' => 'required']])
@@ -106,7 +102,7 @@ @push('scripts') diff --git a/resources/views/data/keluarga/index.blade.php b/resources/views/data/keluarga/index.blade.php index 93ec0a1cba..08d1f3e36c 100644 --- a/resources/views/data/keluarga/index.blade.php +++ b/resources/views/data/keluarga/index.blade.php @@ -68,7 +68,10 @@ url: "{!! route('data.keluarga.getdata') !!}", type: "POST", data: function(d) { - d.desa = $('#list_desa').val(); + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } } }, columns: [{ diff --git a/resources/views/data/laporan-apbdes/index.blade.php b/resources/views/data/laporan-apbdes/index.blade.php index ba3e81786a..0d3117c376 100644 --- a/resources/views/data/laporan-apbdes/index.blade.php +++ b/resources/views/data/laporan-apbdes/index.blade.php @@ -72,7 +72,10 @@ url: "{!! route('data.laporan-apbdes.getdata') !!}", type: "POST", data: function(d) { - d.desa = $('#list_desa').val(); + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } } }, columns: [{ diff --git a/resources/views/data/laporan-penduduk/index.blade.php b/resources/views/data/laporan-penduduk/index.blade.php index d28337509a..c041616724 100644 --- a/resources/views/data/laporan-penduduk/index.blade.php +++ b/resources/views/data/laporan-penduduk/index.blade.php @@ -68,7 +68,10 @@ url: "{!! route('data.laporan-penduduk.getdata') !!}", type: "POST", data: function(d) { - d.desa = $('#list_desa').val(); + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } } }, columns: [{ diff --git a/resources/views/data/pembangunan/index.blade.php b/resources/views/data/pembangunan/index.blade.php index 1e0886754a..544f3c2089 100644 --- a/resources/views/data/pembangunan/index.blade.php +++ b/resources/views/data/pembangunan/index.blade.php @@ -70,7 +70,10 @@ url: "{!! route('data.pembangunan.getdata') !!}", type: "POST", data: function(d) { - d.desa = $('#list_desa').val(); + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } } }, columns: [{ diff --git a/resources/views/data/penduduk/index.blade.php b/resources/views/data/penduduk/index.blade.php index 92b8abb723..801ac1fe82 100644 --- a/resources/views/data/penduduk/index.blade.php +++ b/resources/views/data/penduduk/index.blade.php @@ -72,7 +72,10 @@ url: "{!! route('data.penduduk.getdata') !!}", type: "POST", data: function(d) { - d.desa = $('#list_desa').val(); + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } } }, columns: [{ diff --git a/resources/views/data/program_bantuan/index.blade.php b/resources/views/data/program_bantuan/index.blade.php index e0044707ec..2519b9123e 100644 --- a/resources/views/data/program_bantuan/index.blade.php +++ b/resources/views/data/program_bantuan/index.blade.php @@ -65,7 +65,10 @@ url: "{!! route('data.program-bantuan.getdata') !!}", type: "POST", data: function(d) { - d.desa = $('#list_desa').val(); + var desaId = $('#list_desa').val(); + if (desaId && desaId != 'Semua') { + d.desa = desaId; + } } }, diff --git a/resources/views/data/putus_sekolah/import.blade.php b/resources/views/data/putus_sekolah/import.blade.php index d8f671dad3..871e8ff2e9 100644 --- a/resources/views/data/putus_sekolah/import.blade.php +++ b/resources/views/data/putus_sekolah/import.blade.php @@ -43,11 +43,7 @@
- + @include('layouts.fragments.select-desa', ['selectAttributes' => ['name' => 'desa_id','data-width' => '100%', 'required' => 'required']])
@@ -108,7 +104,7 @@ @push('scripts') From 9dedd964f4868016f287117e31c01bf4f0d155af Mon Sep 17 00:00:00 2001 From: ahmad afandi Date: Mon, 7 Sep 2026 16:01:39 +0700 Subject: [PATCH 14/52] perbaikan helper --- helpers/general_helper.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/helpers/general_helper.php b/helpers/general_helper.php index 2a67b5c956..246f5cf838 100644 --- a/helpers/general_helper.php +++ b/helpers/general_helper.php @@ -748,6 +748,12 @@ function getBulan(int $bln) return $bulan[$bln]; } +function kembalikanSlug($str): ?string +{ + // Ganti '-' dengan spasi dan hilangkan titik '.' + return str_replace(['-', '.'], [' ', ''], $str); +} + if (! function_exists('list_desa')) { function list_desa(bool $all = false) { @@ -761,7 +767,3 @@ function nama_desa(string $desaId): ?string return list_desa()[$desaId] ?? null; } } -{ - // Ganti '-' dengan spasi dan hilangkan titik '.' - return str_replace(['-', '.'], [' ', ''], $str); -} From d196ebf175a3277e6581f52ca05b478b6a15fd7d Mon Sep 17 00:00:00 2001 From: Habibie Date: Mon, 7 Sep 2026 16:46:15 +0700 Subject: [PATCH 15/52] fix tombol sarana ui --- .../views/data/data_sarana/index.blade.php | 4 ++-- .../data_sarana/partials/action.blade.php | 24 ++++++++----------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/resources/views/data/data_sarana/index.blade.php b/resources/views/data/data_sarana/index.blade.php index 20f38c3e07..cd61838b3d 100644 --- a/resources/views/data/data_sarana/index.blade.php +++ b/resources/views/data/data_sarana/index.blade.php @@ -50,7 +50,7 @@
- + @@ -85,7 +85,7 @@ name: 'aksi', orderable: false, searchable: false, - className: 'text-center' + className: 'text-center text-nowrap' }, { data: 'nama', diff --git a/resources/views/data/data_sarana/partials/action.blade.php b/resources/views/data/data_sarana/partials/action.blade.php index 3cb9ac1eb5..7acc2d872a 100644 --- a/resources/views/data/data_sarana/partials/action.blade.php +++ b/resources/views/data/data_sarana/partials/action.blade.php @@ -1,17 +1,13 @@ -
- - +
+ + -
-
- @csrf - @method('DELETE') - - -
+
+ @csrf + @method('DELETE') + +
From 0cf01351ab9cd29566789a3f07f6a8691ceca8e6 Mon Sep 17 00:00:00 2001 From: Habibie Date: Mon, 7 Sep 2026 17:31:45 +0700 Subject: [PATCH 16/52] Fix: tampilan datatable overlapping --- public/css/admin-style.css | 8 ++- public/js/admin.js | 55 +++++++++++++++++++ resources/views/data/pengurus/index.blade.php | 29 +++++++--- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/public/css/admin-style.css b/public/css/admin-style.css index 187c0bdd15..82fdfae93f 100644 --- a/public/css/admin-style.css +++ b/public/css/admin-style.css @@ -92,12 +92,18 @@ body.sidebar-collapse .sidebar-kabupaten { /* Prevent dropdown clipping inside DataTable responsive wrapper */ .box-body .table-responsive { - overflow: visible !important; + overflow-x: auto; } .box-body .table-responsive .dropdown-menu { z-index: 1050; } +/* Ensure nowrap tables do not wrap cells and preserve header spacing */ +.table.nowrap th, +.table.nowrap td { + white-space: nowrap; +} + /* Simple centered modal for activity detail */ .modal-overlay { position: fixed; diff --git a/public/js/admin.js b/public/js/admin.js index 445a5c445a..1e2cb30252 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -5,6 +5,61 @@ $(function() { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') } }); + + // Fix dropdown clipping inside scrollable DataTables and responsive tables + $(document).on('shown.bs.dropdown', '.table-responsive, .dataTables_scrollBody', function(e) { + var $dropdown = $(e.target); + var $menu = $dropdown.find('.dropdown-menu'); + if (!$menu.length) return; + + $('body').append($menu.detach()); + + function updateDropdownPosition() { + if (!$dropdown.is(':visible') || !$dropdown.hasClass('open')) { + $dropdown.trigger('hide.bs.dropdown'); + return; + } + var offset = $dropdown.offset(); + var menuWidth = $menu.outerWidth(); + var left = offset.left; + + if (left + menuWidth > $(window).width()) { + left = offset.left + $dropdown.outerWidth() - menuWidth; + } + + $menu.css({ + display: 'block', + position: 'absolute', + top: (offset.top + $dropdown.outerHeight()) + 'px', + left: left + 'px', + zIndex: 1060 + }); + } + + updateDropdownPosition(); + $dropdown.data('detached-dropdown-menu', $menu); + + $(window).on('scroll.dropdown_fix resize.dropdown_fix', updateDropdownPosition); + $('.dataTables_scrollBody, .table-responsive').on('scroll.dropdown_fix', updateDropdownPosition); + }); + + $(document).on('hide.bs.dropdown', '.table-responsive, .dataTables_scrollBody', function(e) { + var $dropdown = $(e.target); + var $menu = $dropdown.data('detached-dropdown-menu'); + if ($menu && $menu.length) { + $dropdown.append($menu.detach()); + $menu.css({ + display: '', + position: '', + top: '', + left: '', + zIndex: '' + }); + $dropdown.removeData('detached-dropdown-menu'); + } + $(window).off('.dropdown_fix'); + $('.dataTables_scrollBody, .table-responsive').off('.dropdown_fix'); + }); }); function errorValidation(response) { diff --git a/resources/views/data/pengurus/index.blade.php b/resources/views/data/pengurus/index.blade.php index c90bbf6381..e3be8e2488 100644 --- a/resources/views/data/pengurus/index.blade.php +++ b/resources/views/data/pengurus/index.blade.php @@ -4,6 +4,14 @@ Data Pengurus @endsection +@push('css') + +@endpush + @section('content')

@@ -44,18 +52,18 @@


-
AksiAksi Nama Sarana Jumlah Kategori
+
- - - - + + + + - + @@ -79,6 +87,8 @@ var data = $('#pengurus-table').DataTable({ processing: true, serverSide: true, + scrollX: true, + autoWidth: false, ajax: { url: "{!! route('data.pengurus.getdata.post') !!}", type: "POST", @@ -130,7 +140,8 @@ class: 'text-center', }, { data: 'status', - name: 'status' + name: 'status', + class: 'text-center' }, { data: 'pendidikan.nama', @@ -163,6 +174,10 @@ class: 'text-center', $('#status').on('change', function(e) { data.ajax.reload(); }); + + $(window).on('resize', function() { + data.columns.adjust(); + }); }); @include('forms.datatable-vertical') From 17011a666e667e2d5ea31aa17adf5fc55716a2ef Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 8 Sep 2026 07:49:25 +0700 Subject: [PATCH 17/52] Perbaikan lembaga dan anggota untuk database gabungan --- .../Data/LembagaAnggotaController.php | 141 +++++++++---- .../Controllers/Data/LembagaController.php | 84 +++++--- .../Requests/StoreLembagaAnggotaRequest.php | 21 +- app/Http/Requests/StoreLembagaRequest.php | 23 +-- .../Requests/UpdateLembagaAnggotaRequest.php | 18 +- app/Http/Requests/UpdateLembagaRequest.php | 25 +-- app/Models/Lembaga.php | 5 + app/Models/LembagaAnggota.php | 6 +- app/Services/PendudukService.php | 32 ++- ...penduduk_id_gabungan_to_lembaga_tables.php | 40 ++++ .../penduduk-gabungan-select.blade.php | 81 ++++++++ .../data/lembaga/gabungan/create.blade.php | 37 ++++ .../data/lembaga/gabungan/edit.blade.php | 37 ++++ .../lembaga/gabungan/form_create.blade.php | 53 +++++ .../data/lembaga/gabungan/form_edit.blade.php | 59 ++++++ .../lembaga_anggota/gabungan/create.blade.php | 41 ++++ .../lembaga_anggota/gabungan/edit.blade.php | 48 +++++ .../gabungan/form_create.blade.php | 99 +++++++++ .../gabungan/form_edit.blade.php | 101 +++++++++ .../data/lembaga_anggota/index.blade.php | 5 +- tests/Feature/LembagaControllerTest.php | 192 ++++++++++++++++++ 21 files changed, 1042 insertions(+), 106 deletions(-) create mode 100644 database/migrations/2026_09_08_055444_add_penduduk_id_gabungan_to_lembaga_tables.php create mode 100644 resources/views/components/penduduk-gabungan-select.blade.php create mode 100644 resources/views/data/lembaga/gabungan/create.blade.php create mode 100644 resources/views/data/lembaga/gabungan/edit.blade.php create mode 100644 resources/views/data/lembaga/gabungan/form_create.blade.php create mode 100644 resources/views/data/lembaga/gabungan/form_edit.blade.php create mode 100644 resources/views/data/lembaga_anggota/gabungan/create.blade.php create mode 100644 resources/views/data/lembaga_anggota/gabungan/edit.blade.php create mode 100644 resources/views/data/lembaga_anggota/gabungan/form_create.blade.php create mode 100644 resources/views/data/lembaga_anggota/gabungan/form_edit.blade.php create mode 100644 tests/Feature/LembagaControllerTest.php diff --git a/app/Http/Controllers/Data/LembagaAnggotaController.php b/app/Http/Controllers/Data/LembagaAnggotaController.php index 386de9a6d5..068618de55 100644 --- a/app/Http/Controllers/Data/LembagaAnggotaController.php +++ b/app/Http/Controllers/Data/LembagaAnggotaController.php @@ -2,11 +2,13 @@ namespace App\Http\Controllers\Data; +use App\Models\SettingAplikasi; use Carbon\Carbon; use App\Models\Lembaga; use App\Models\Penduduk; use Illuminate\Http\Request; use App\Models\LembagaAnggota; +use App\Services\PendudukService; use Yajra\DataTables\DataTables; use App\Http\Controllers\Controller; use App\Http\Requests\StoreLembagaAnggotaRequest; @@ -38,17 +40,23 @@ public function getData(Request $request, $slug) if ($request->ajax()) { $lembaga = Lembaga::where('slug', $slug)->firstOrFail(); - // Ambil data anggota lembaga dengan informasi lembaga dan penduduk terkait $anggotaList = LembagaAnggota::with(['lembaga', 'penduduk']) ->where('lembaga_id', $lembaga->id) ->get(); + $pendudukGabunganIds = $anggotaList->pluck('penduduk_id_gabungan')->filter()->unique()->values()->all(); + + $pendudukGabungan = collect(); + if ($this->isDatabaseGabungan() && !empty($pendudukGabunganIds)) { + $pendudukGabungan = (new PendudukService())->pendudukGabunganByIds($pendudukGabunganIds); + } + return DataTables::of($anggotaList) ->addIndexColumn() ->addColumn('aksi', function ($row) use ($slug) { if (!auth()->guest()) { - $data['edit_url'] = auth()->user()->can('access.data.lembaga_anggota.edit') ? route('data.lembaga_anggota.edit', ['slug' => $slug, 'id' => $row->id]) : null; - $data['delete_url'] = auth()->user()->can('access.data.lembaga_anggota.delete') ? route('data.lembaga_anggota.destroy', ['slug' => $slug, 'id' => $row->id]) : null; + $data['edit_url'] = auth()->user()->can('access.data.lembaga.edit') ? route('data.lembaga_anggota.edit', ['slug' => $slug, 'id' => $row->id]) : null; + $data['delete_url'] = auth()->user()->can('access.data.lembaga.delete') ? route('data.lembaga_anggota.destroy', ['slug' => $slug, 'id' => $row->id]) : null; } return view('forms.aksi', $data); @@ -56,31 +64,80 @@ public function getData(Request $request, $slug) ->addColumn('no_anggota', function ($row) { return $row->no_anggota ?: '-'; }) - ->addColumn('nik', function ($row) { + ->addColumn('nik', function ($row) use ($pendudukGabungan) { + if ($this->isDatabaseGabungan()) { + $penduduk = $pendudukGabungan->firstWhere('id', $row->penduduk_id_gabungan); + + return $penduduk ? ($penduduk['attributes']['nik'] ?? '-') : '-'; + } + return $row->penduduk->nik ?? '-'; }) - ->addColumn('nama', function ($row) { + ->addColumn('nama', function ($row) use ($pendudukGabungan) { + if ($this->isDatabaseGabungan()) { + $penduduk = $pendudukGabungan->firstWhere('id', $row->penduduk_id_gabungan); + + return $penduduk ? ($penduduk['attributes']['nama'] ?? '-') : '-'; + } + return $row->penduduk->nama ?? '-'; }) - ->addColumn('tempat_tgl_lahir', function ($row) { + ->addColumn('tempat_tgl_lahir', function ($row) use ($pendudukGabungan) { + if ($this->isDatabaseGabungan()) { + $penduduk = $pendudukGabungan->firstWhere('id', $row->penduduk_id_gabungan); + + $tempat = $penduduk ? ($penduduk['attributes']['tempatlahir'] ?? '-') : '-'; + $tanggalLahir = $penduduk && ($penduduk['attributes']['tanggallahir'] ?? false) + ? Carbon::parse($penduduk['attributes']['tanggallahir'])->translatedFormat('d F Y') + : '-'; + + return "$tempat / $tanggalLahir"; + } + $tempat = $row->penduduk->tempat_lahir ?? '-'; $tanggalLahir = $row->penduduk->tanggal_lahir ? Carbon::parse($row->penduduk->tanggal_lahir)->translatedFormat('d F Y') : '-'; + return "$tempat / $tanggalLahir"; }) - ->addColumn('umur', function ($row) { + ->addColumn('umur', function ($row) use ($pendudukGabungan) { + if ($this->isDatabaseGabungan()) { + $penduduk = $pendudukGabungan->firstWhere('id', $row->penduduk_id_gabungan); + + return $penduduk['attributes']['umur'] ?? '-'; + } + return $row->penduduk && $row->penduduk->tanggal_lahir - ? \Carbon\Carbon::parse($row->penduduk->tanggal_lahir)->age + ? Carbon::parse($row->penduduk->tanggal_lahir)->age : '-'; }) - ->addColumn('sex', function ($row) { + ->addColumn('sex', function ($row) use ($pendudukGabungan) { + if ($this->isDatabaseGabungan()) { + $penduduk = $pendudukGabungan->firstWhere('id', $row->penduduk_id_gabungan); + $sex = ['1' => 'Laki-laki', '2' => 'Perempuan']; + return $penduduk ? ($sex[$penduduk['attributes']['sex']] ?? '-') : '-'; + } + return $row->penduduk && $row->penduduk->pendudukSex ? $row->penduduk->pendudukSex->nama : '-'; }) - ->addColumn('alamat', function ($row) { + ->addColumn('alamat', function ($row) use ($pendudukGabungan) { + if ($this->isDatabaseGabungan()) { + $penduduk = $pendudukGabungan->firstWhere('id', $row->penduduk_id_gabungan); + + if (!$penduduk) { + return '-'; + } + + $alamat = $penduduk['attributes']['alamat_wilayah'] ?? '-'; + + return $alamat; + } + $rt = $row->penduduk->rt ?? '-'; $rw = $row->penduduk->rw ?? '-'; $alamat = $row->penduduk->dusun ?? '-'; + return "RT $rt / RW $rw $alamat"; }) ->addColumn('jabatan', function ($row) { @@ -136,15 +193,18 @@ public function getData(Request $request, $slug) */ public function create($slug) { - // Cari lembaga berdasarkan slug $lembaga = Lembaga::where('slug', $slug)->firstOrFail(); - // Ambil anggota yang sudah ada di lembaga ini + if ($this->isDatabaseGabungan()) { + $page_title = 'Tambah Anggota Lembaga ' . $lembaga->nama; + $page_description = 'Tambah Anggota Lembaga ' . $lembaga->nama; + + return view('data.lembaga_anggota.gabungan.create', compact('page_title', 'page_description', 'lembaga')); + } + $existingAnggotaIds = $lembaga->lembagaAnggota->pluck('penduduk_id')->toArray(); - // Ambil semua penduduk yang belum menjadi anggota $pendudukList = Penduduk::whereNotIn('id', $existingAnggotaIds)->get()->mapWithKeys(function ($penduduk) { - // Membuat string yang menggabungkan NIK, nama, dan alamat $optionText = "NIK: {$penduduk->nik} - {$penduduk->nama} - Dusun {$penduduk->dusun} RT {$penduduk->rt} / RW {$penduduk->rw}"; return [$penduduk->id => $optionText]; @@ -165,24 +225,25 @@ public function create($slug) */ public function store(StoreLembagaAnggotaRequest $request, $slug) { - // Cari lembaga berdasarkan slug $lembaga = Lembaga::where('slug', $slug)->firstOrFail(); - // Cek apakah jabatan yang dipilih adalah Ketua (jabatan_id = 1) if ($request->jabatan_id == 1) { - // Cek apakah sudah ada Ketua di lembaga ini $existingKetua = $lembaga->lembagaAnggota()->where('jabatan', 1)->first(); - // Jika ada, ubah jabatan ketua yang sudah ada menjadi Anggota (jabatan_id = 5) if ($existingKetua) { $existingKetua->update(['jabatan' => 5]); } } - try { + $data = $request->validated(); - $lembaga->lembagaAnggota()->create([ - 'penduduk_id' => $request->penduduk_id, + if (SettingAplikasi::where('key', 'sinkronisasi_database_gabungan')->value('value') === '1') { + $data['penduduk_id'] = null; + $data['penduduk_id_gabungan'] = $data['penduduk_id_gabungan'] ?? null; + } + + try { + $lembaga->lembagaAnggota()->create($data + [ 'no_anggota' => $request->no_anggota, 'jabatan' => $request->jabatan_id, 'no_sk_jabatan' => $request->no_sk_jabatan, @@ -193,7 +254,6 @@ public function store(StoreLembagaAnggotaRequest $request, $slug) 'periode' => $request->periode, 'keterangan' => $request->keterangan, ]); - } catch (\Exception $e) { Log::error('Lembaga Anggota creation failed', [ 'error' => $e->getMessage(), @@ -216,13 +276,24 @@ public function store(StoreLembagaAnggotaRequest $request, $slug) */ public function edit($slug, $id) { - // Cari lembaga berdasarkan slug $lembaga = Lembaga::where('slug', $slug)->firstOrFail(); - - // Cari anggota lembaga berdasarkan ID $anggota = LembagaAnggota::with('penduduk')->where('lembaga_id', $lembaga->id)->findOrFail($id); - // Ambil semua penduduk yang belum menjadi anggota kecuali yang sedang diedit + if ($this->isDatabaseGabungan()) { + $page_title = 'Ubah Anggota Lembaga ' . $lembaga->nama; + $page_description = 'Ubah Anggota Lembaga ' . $lembaga->nama; + + $pendudukGabungan = (new PendudukService())->pendudukGabunganByIds([$anggota->penduduk_id_gabungan]); + $penduduk = new Penduduk(); + if($pendudukGabungan[0]){ + $penduduk->nama = $pendudukGabungan[0]['attributes']['nama']; + $penduduk->nik = $pendudukGabungan[0]['attributes']['nik']; + } + $anggota->setRelation('penduduk', $penduduk); + + return view('data.lembaga_anggota.gabungan.edit', compact('page_title', 'page_description', 'lembaga', 'anggota')); + } + $existingAnggotaIds = $lembaga->lembagaAnggota() ->where('id', '!=', $anggota->id) ->pluck('penduduk_id') @@ -233,7 +304,6 @@ public function edit($slug, $id) return [$penduduk->id => $optionText]; }); - // Tambahkan penduduk saat ini (yang sedang diedit) ke daftar pendudukList $pendudukList->prepend("NIK: {$anggota->penduduk->nik} - {$anggota->penduduk->nama} - Dusun {$anggota->penduduk->dusun} RT {$anggota->penduduk->rt} / RW {$anggota->penduduk->rw}", $anggota->penduduk->id); $page_title = 'Ubah Anggota Lembaga ' . $lembaga->nama; @@ -252,28 +322,28 @@ public function edit($slug, $id) public function update(UpdateLembagaAnggotaRequest $request, $slug, $id) { try { - - // Cari lembaga berdasarkan slug $lembaga = Lembaga::where('slug', $slug)->firstOrFail(); - // Cari anggota berdasarkan id dan pastikan anggota tersebut milik lembaga yang sesuai $anggota = LembagaAnggota::where('id', $id) ->where('lembaga_id', $lembaga->id) ->firstOrFail(); - // Cek apakah jabatan yang dipilih adalah Ketua (jabatan_id = 1) if ($request->jabatan_id == 1) { - // Cek apakah sudah ada Ketua di lembaga ini $existingKetua = $lembaga->lembagaAnggota()->where('jabatan', 1)->first(); - // Jika ada Ketua lain, ubah jabatan Ketua yang sudah ada menjadi Anggota (jabatan_id = 5) if ($existingKetua && $existingKetua->id !== $anggota->id) { $existingKetua->update(['jabatan' => 5]); } } - // Update data anggota - $anggota->update([ + $data = $request->validated(); + + if (SettingAplikasi::where('key', 'sinkronisasi_database_gabungan')->value('value') === '1') { + $data['penduduk_id'] = null; + $data['penduduk_id_gabungan'] = $data['penduduk_id_gabungan'] ?? null; + } + + $anggota->update($data + [ 'no_anggota' => $request->no_anggota, 'jabatan' => $request->jabatan_id, 'no_sk_jabatan' => $request->no_sk_jabatan, @@ -284,7 +354,6 @@ public function update(UpdateLembagaAnggotaRequest $request, $slug, $id) 'periode' => $request->periode, 'keterangan' => $request->keterangan, ]); - } catch (\Exception $e) { Log::error('Lembaga Anggota update failed', [ 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Data/LembagaController.php b/app/Http/Controllers/Data/LembagaController.php index 8cb9851c60..fd38ed68a8 100644 --- a/app/Http/Controllers/Data/LembagaController.php +++ b/app/Http/Controllers/Data/LembagaController.php @@ -2,17 +2,18 @@ namespace App\Http\Controllers\Data; +use App\Http\Controllers\Controller; +use App\Http\Requests\StoreLembagaRequest; +use App\Http\Requests\UpdateLembagaRequest; +use App\Models\KategoriLembaga; use App\Models\Lembaga; +use App\Models\LembagaAnggota; use App\Models\Penduduk; +use App\Services\PendudukService; use Illuminate\Http\Request; -use App\Models\LembagaAnggota; -use App\Models\KategoriLembaga; -use Yajra\DataTables\DataTables; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use App\Http\Controllers\Controller; -use App\Http\Requests\StoreLembagaRequest; -use App\Http\Requests\UpdateLembagaRequest; +use Yajra\DataTables\DataTables; class LembagaController extends Controller { @@ -35,14 +36,22 @@ public function index() public function getData(Request $request) { if ($request->ajax()) { - $lembagaList = Lembaga::with(['lembagaKategori', 'penduduk', 'lembagaAnggota']) + $lembagaList = Lembaga::with(['lembagaKategori', 'penduduk', 'pendudukGabungan', 'lembagaAnggota']) ->withCount('lembagaAnggota as jml_anggota') ->get(); + + $pendudukGabunganIds = $lembagaList->pluck('penduduk_id_gabungan')->filter()->unique()->values()->all(); + + $pendudukGabungan = collect(); + if ($this->isDatabaseGabungan() && !empty($pendudukGabunganIds)) { + $pendudukGabungan = (new PendudukService())->pendudukGabunganByIds($pendudukGabunganIds); + } + return DataTables::of($lembagaList) ->addIndexColumn() ->addColumn('aksi', function ($row) { if (!auth()->guest()) { - $data['detail_url'] = auth()->user()->can('access.data.lembaga_anggota.view') ? route('data.lembaga_anggota.index', $row->slug) : null; + $data['detail_url'] = auth()->user()->can('access.data.lembaga.view') ? route('data.lembaga_anggota.index', $row->slug) : null; $data['edit_url'] = auth()->user()->can('access.data.lembaga.edit') ? route('data.lembaga.edit', $row->id) : null; $data['delete_url'] = auth()->user()->can('access.data.lembaga.delete') ? route('data.lembaga.destroy', $row->id) : null; } @@ -52,7 +61,13 @@ public function getData(Request $request) ->addColumn('kategori', function ($row) { return $row->lembagaKategori ? $row->lembagaKategori->nama : '-'; }) - ->addColumn('ketua', function ($row) { + ->addColumn('ketua', function ($row) use ($pendudukGabungan) { + if ($this->isDatabaseGabungan()) { + $penduduk = $pendudukGabungan->firstWhere('id', $row->penduduk_id_gabungan); + + return $penduduk ? ($penduduk['attributes']['nama'] ?? '-') : '-'; + } + return $row->penduduk ? $row->penduduk->nama : '-'; }) ->rawColumns(['aksi']) @@ -69,12 +84,15 @@ public function create() { $page_title = $this->title; $page_description = 'Tambah ' . $this->title; + + if ($this->isDatabaseGabungan()) { + $view = 'data.lembaga.gabungan.create'; + return view($view, compact('page_title', 'page_description')); + } + $kategoriLembagaList = KategoriLembaga::pluck('nama', 'id'); $pendudukList = Penduduk::all()->mapWithKeys(function ($penduduk) { - // Membuat string yang menggabungkan NIK, nama, dan alamat $optionText = "NIK: {$penduduk->nik} - {$penduduk->nama} - Dusun {$penduduk->dusun} RT {$penduduk->rt} / RW {$penduduk->rw}"; - - // Menambahkan ke array dengan 'id' sebagai key dan $optionText sebagai value return [$penduduk->id => $optionText]; }); @@ -85,24 +103,28 @@ public function create() * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request + * * @return \Illuminate\Http\Response */ public function store(StoreLembagaRequest $request) { $data = $request->all(); - // Menggunakan scope untuk menghasilkan slug yang unik + if ($this->isDatabaseGabungan()) { + $data['penduduk_id'] = null; + $data['penduduk_id_gabungan'] = $data['penduduk_id_gabungan'] ?? null; + } + $data['slug'] = Lembaga::generateUniqueSlug($request->nama); try { DB::transaction(function () use ($data) { - // Insert ke table 'lembaga' $lembaga = Lembaga::create($data); - // Insert ke tabel 'lembagaanggota' dengan id penduduk yang diambil dari input LembagaAnggota::create([ 'lembaga_id' => $lembaga->id, 'penduduk_id' => $data['penduduk_id'], + 'penduduk_id_gabungan' => $data['penduduk_id_gabungan'] ?? null, 'no_anggota' => 1, 'jabatan' => 1, 'keterangan' => 'Ketua lembaga' @@ -125,6 +147,7 @@ public function store(StoreLembagaRequest $request) * Display the specified resource. * * @param int $id + * * @return \Illuminate\Http\Response */ public function show($id) @@ -136,6 +159,7 @@ public function show($id) * Show the form for editing the specified resource. * * @param int $id + * * @return \Illuminate\Http\Response */ public function edit($id) @@ -143,12 +167,22 @@ public function edit($id) $lembaga = Lembaga::findOrFail($id); $page_title = $this->title; $page_description = 'Ubah ' . $this->title; + + if ($this->isDatabaseGabungan()) { + $view = 'data.lembaga.gabungan.edit'; + $pendudukGabungan = (new PendudukService())->pendudukGabunganByIds([$lembaga->penduduk_id_gabungan]); + $penduduk = new Penduduk(); + if($pendudukGabungan[0]){ + $penduduk->nama = $pendudukGabungan[0]['attributes']['nama']; + $penduduk->nik = $pendudukGabungan[0]['attributes']['nik']; + } + $lembaga->setRelation('penduduk', $penduduk); + return view($view, compact('page_title', 'page_description', 'lembaga')); + } + $kategoriLembagaList = KategoriLembaga::pluck('nama', 'id'); $pendudukList = Penduduk::all()->mapWithKeys(function ($penduduk) { - // Membuat string yang menggabungkan NIK, nama, dan alamat $optionText = "NIK: {$penduduk->nik} - {$penduduk->nama} - Dusun {$penduduk->dusun} RT {$penduduk->rt} / RW {$penduduk->rw}"; - - // Menambahkan ke array dengan 'id' sebagai key dan $optionText sebagai value return [$penduduk->id => $optionText]; }); @@ -160,6 +194,7 @@ public function edit($id) * * @param \Illuminate\Http\Request $request * @param int $id + * * @return \Illuminate\Http\Response */ public function update(UpdateLembagaRequest $request, $id) @@ -168,24 +203,25 @@ public function update(UpdateLembagaRequest $request, $id) try { DB::transaction(function () use ($data, $request, $id) { - // Temukan lembaga yang akan diupdate $lembaga = Lembaga::findOrFail($id); - // Hanya buat slug baru jika nama lembaga berubah if ($request->nama !== $lembaga->nama) { $data['slug'] = Lembaga::generateUniqueSlug($request->nama); } - // Update data lembaga + if ($this->isDatabaseGabungan()) { + $data['penduduk_id'] = null; + $data['penduduk_id_gabungan'] = $data['penduduk_id_gabungan'] ?? null; + } + $lembaga->update($data); - // Cek apakah anggota terkait sudah ada $anggota = LembagaAnggota::where('lembaga_id', $lembaga->id)->first(); if ($anggota) { - // Update penduduk_id pada anggota jika anggota sudah ada $anggota->update([ 'penduduk_id' => $data['penduduk_id'] ?? $lembaga->penduduk_id, + 'penduduk_id_gabungan' => $data['penduduk_id_gabungan'] ?? $lembaga->penduduk_id_gabungan, ]); } }); @@ -202,11 +238,11 @@ public function update(UpdateLembagaRequest $request, $id) return redirect()->route('data.lembaga.index')->with('success', 'Lembaga berhasil diubah!'); } - /** * Remove the specified resource from storage. * * @param int $id + * * @return \Illuminate\Http\Response */ public function destroy($id) diff --git a/app/Http/Requests/StoreLembagaAnggotaRequest.php b/app/Http/Requests/StoreLembagaAnggotaRequest.php index 17be9f95f6..f60fd0a0de 100644 --- a/app/Http/Requests/StoreLembagaAnggotaRequest.php +++ b/app/Http/Requests/StoreLembagaAnggotaRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Models\Lembaga; +use App\Models\SettingAplikasi; use Illuminate\Validation\Rule; use Illuminate\Foundation\Http\FormRequest; @@ -25,9 +26,22 @@ public function authorize() */ public function rules() { - $slug = $this->route('slug'); + $slug = $this->route('slug'); $lembaga = Lembaga::where('slug', $slug)->first(); - + + if (SettingAplikasi::where('key', 'sinkronisasi_database_gabungan')->value('value') === '1') { + return [ + 'penduduk_id' => 'nullable|integer', + 'penduduk_id_gabungan' => 'required|integer', + 'no_anggota' => [ + 'required', + Rule::unique('das_lembaga_anggota', 'no_anggota') + ->where('lembaga_id', $lembaga->id), + ], + 'jabatan_id' => 'required|in:1,2,3,4,5', + ]; + } + return [ 'penduduk_id' => 'required|exists:das_penduduk,id', 'no_anggota' => [ @@ -35,7 +49,7 @@ public function rules() Rule::unique('das_lembaga_anggota', 'no_anggota') ->where('lembaga_id', $lembaga->id), ], - 'jabatan_id' => 'required|in:1,2,3,4,5', // Kode jabatan harus 1-5 + 'jabatan_id' => 'required|in:1,2,3,4,5', ]; } @@ -48,6 +62,7 @@ public function attributes() { return [ 'penduduk_id' => 'Nama Anggota', + 'penduduk_id_gabungan' => 'Nama Anggota', 'no_anggota' => 'Nomor Anggota', 'jabatan_id' => 'Jabatan', ]; diff --git a/app/Http/Requests/StoreLembagaRequest.php b/app/Http/Requests/StoreLembagaRequest.php index 0bd7d662c3..32a7b49884 100644 --- a/app/Http/Requests/StoreLembagaRequest.php +++ b/app/Http/Requests/StoreLembagaRequest.php @@ -2,33 +2,31 @@ namespace App\Http\Requests; +use App\Models\SettingAplikasi; use Illuminate\Foundation\Http\FormRequest; class StoreLembagaRequest extends FormRequest { - /** - * Determine if the user is authorized to make this request. - * - * @return bool - */ public function authorize() { return true; } - /** - * Get the validation rules that apply to the request. - * - * @return array - */ public function rules() { - return [ + $rules = [ 'nama' => 'required|string|max:255', 'kode' => 'required|string|max:255|unique:das_lembaga,kode', 'lembaga_kategori_id' => 'required|exists:das_lembaga_kategori,id', - 'penduduk_id' => 'required|exists:das_penduduk,id', ]; + + if (SettingAplikasi::where('key', 'sinkronisasi_database_gabungan')->value('value') === '1') { + $rules['penduduk_id_gabungan'] = 'required|integer'; + } else { + $rules['penduduk_id'] = 'required|exists:das_penduduk,id'; + } + + return $rules; } /** @@ -43,6 +41,7 @@ public function attributes() 'kode' => 'Kode Lembaga', 'lembaga_kategori_id' => 'Kategori Lembaga', 'penduduk_id' => 'Ketua Lembaga', + 'penduduk_id_gabungan' => 'Ketua Lembaga', ]; } } diff --git a/app/Http/Requests/UpdateLembagaAnggotaRequest.php b/app/Http/Requests/UpdateLembagaAnggotaRequest.php index 6a05da04a5..4bf1a75194 100644 --- a/app/Http/Requests/UpdateLembagaAnggotaRequest.php +++ b/app/Http/Requests/UpdateLembagaAnggotaRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Models\LembagaAnggota; +use App\Models\SettingAplikasi; use Illuminate\Foundation\Http\FormRequest; class UpdateLembagaAnggotaRequest extends FormRequest @@ -25,22 +26,23 @@ public function authorize() public function rules() { $rules = [ - // 'no_anggota' => 'required|unique:das_lembaga_anggota,no_anggota', // pastikan id penduduk ada dalam tabel - 'jabatan_id' => 'required|in:1,2,3,4,5', // kode jabatan harus 1-5 + 'jabatan_id' => 'required|in:1,2,3,4,5', ]; $id = $this->route('id'); - // cek no_anggota berdasarkan id $anggota = LembagaAnggota::findOrFail($id); - - // jika inputan nomor anggota tidak sama dengan data no_anggota - if($this->input('no_anggota') !== $anggota->no_anggota){ + + if ($this->input('no_anggota') !== $anggota->no_anggota) { $rules['no_anggota'] = 'required|unique:das_lembaga_anggota,no_anggota'; } - return $rules; + if (SettingAplikasi::where('key', 'sinkronisasi_database_gabungan')->value('value') === '1') { + $rules['penduduk_id'] = 'nullable|integer'; + $rules['penduduk_id_gabungan'] = 'required|integer'; + } + return $rules; } /** @@ -51,6 +53,8 @@ public function rules() public function attributes() { return [ + 'penduduk_id' => 'Nama Anggota', + 'penduduk_id_gabungan' => 'Nama Anggota', 'no_anggota' => 'Nomor Anggota', 'jabatan_id' => 'Jabatan', ]; diff --git a/app/Http/Requests/UpdateLembagaRequest.php b/app/Http/Requests/UpdateLembagaRequest.php index 4d75072de9..b93b60cad8 100644 --- a/app/Http/Requests/UpdateLembagaRequest.php +++ b/app/Http/Requests/UpdateLembagaRequest.php @@ -3,43 +3,37 @@ namespace App\Http\Requests; use App\Models\Lembaga; +use App\Models\SettingAplikasi; use Illuminate\Foundation\Http\FormRequest; class UpdateLembagaRequest extends FormRequest { - /** - * Determine if the user is authorized to make this request. - * - * @return bool - */ public function authorize() { return true; } - /** - * Get the validation rules that apply to the request. - * - * @return array - */ public function rules() { $rules = [ 'nama' => 'required|string|max:255', 'lembaga_kategori_id' => 'required|exists:das_lembaga_kategori,id', - 'penduduk_id' => 'required|exists:das_penduduk,id', ]; - // Dapatkan id lembaga dari route $id = $this->route('id'); - - // Cek apakah field kode mengalami perubahan $lembaga = Lembaga::findOrFail($id); - + if ($this->input('kode') !== $lembaga->kode) { $rules['kode'] = 'required|string|max:255|unique:das_lembaga,kode'; } + if (SettingAplikasi::where('key', 'sinkronisasi_database_gabungan')->value('value') === '1') { + $rules['penduduk_id'] = 'nullable|integer'; + $rules['penduduk_id_gabungan'] = 'required|integer'; + } else { + $rules['penduduk_id'] = 'required|exists:das_penduduk,id'; + } + return $rules; } @@ -55,6 +49,7 @@ public function attributes() 'kode' => 'Kode Lembaga', 'lembaga_kategori_id' => 'Kategori Lembaga', 'penduduk_id' => 'Ketua Lembaga', + 'penduduk_id_gabungan' => 'Ketua Lembaga', ]; } } diff --git a/app/Models/Lembaga.php b/app/Models/Lembaga.php index 12a8cb4583..c74100c757 100644 --- a/app/Models/Lembaga.php +++ b/app/Models/Lembaga.php @@ -31,6 +31,11 @@ public function penduduk() return $this->belongsTo(Penduduk::class, 'penduduk_id', 'id'); } + public function pendudukGabungan() + { + return $this->belongsTo(Penduduk::class, 'penduduk_id_gabungan', 'id'); + } + // Scope untuk membuat slug yang unik public static function generateUniqueSlug($nama) { diff --git a/app/Models/LembagaAnggota.php b/app/Models/LembagaAnggota.php index 8f57d0053e..cfd43179ef 100644 --- a/app/Models/LembagaAnggota.php +++ b/app/Models/LembagaAnggota.php @@ -18,12 +18,16 @@ public function lembaga() return $this->belongsTo(Lembaga::class, 'lembaga_id'); } - // Relasi ke model Penduduk (Many-to-One) public function penduduk() { return $this->belongsTo(Penduduk::class, 'penduduk_id'); } + public function pendudukGabungan() + { + return $this->belongsTo(Penduduk::class, 'penduduk_id_gabungan'); + } + // Menentukan kolom slug yang digunakan untuk pencarian di rute public function getRouteKeyName() { diff --git a/app/Services/PendudukService.php b/app/Services/PendudukService.php index 8372a9959f..bbe41dce7a 100644 --- a/app/Services/PendudukService.php +++ b/app/Services/PendudukService.php @@ -3,14 +3,13 @@ namespace App\Services; use App\Models\Penduduk; -use App\Models\SettingAplikasi; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class PendudukService extends BaseApiService { /** - * Get Unique Desa + * Get Unique Desa. */ public function jumlahPenduduk(array $filters = []) { @@ -33,7 +32,7 @@ public function jumlahPenduduk(array $filters = []) } /** - * Get Unique Desa + * Get Unique Desa. */ public function desa(array $filters = []) { @@ -59,7 +58,7 @@ public function desa(array $filters = []) } /** - * Export Data Penduduk + * Export Data Penduduk. */ public function exportPenduduk($size, $number, $search) { @@ -110,7 +109,30 @@ public function detailPenduduk($id) } /** - * Export Data Penduduk + * Ambil beberapa data penduduk dari database gabungan sekaligus + * menggunakan filter id_penduduk berupa array. + * + * @param array $ids + * + * @return \Illuminate\Support\Collection + */ + public function pendudukGabunganByIds(array $ids) + { + if (empty($ids)) { + return collect(); + } + + $data = $this->apiRequest('/api/v1/opendk/sync-penduduk-opendk', [ + 'filter[kode_kecamatan]' => str_replace('.', '', config('profil.kecamatan_id')), + 'filter[id_penduduk]' => $ids, + 'page[size]' => count($ids), + ]); + + return collect($data)->filter(fn (mixed $item): bool => is_array($item)); + } + + /** + * Export Data Penduduk. */ public function cekPendudukNikTanggalLahir($nik, $tgl_lhr = null) { diff --git a/database/migrations/2026_09_08_055444_add_penduduk_id_gabungan_to_lembaga_tables.php b/database/migrations/2026_09_08_055444_add_penduduk_id_gabungan_to_lembaga_tables.php new file mode 100644 index 0000000000..fa3e4c9d3a --- /dev/null +++ b/database/migrations/2026_09_08_055444_add_penduduk_id_gabungan_to_lembaga_tables.php @@ -0,0 +1,40 @@ +unsignedInteger('penduduk_id_gabungan')->nullable()->after('penduduk_id'); + }); + + Schema::table('das_lembaga_anggota', function (Blueprint $table) { + $table->unsignedInteger('penduduk_id_gabungan')->nullable()->after('penduduk_id'); + $table->dropForeign(['penduduk_id']); + $table->unsignedInteger('penduduk_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('das_lembaga', function (Blueprint $table) { + $table->dropColumn('penduduk_id_gabungan'); + }); + + Schema::table('das_lembaga_anggota', function (Blueprint $table) { + $table->unsignedInteger('penduduk_id')->nullable(false)->change(); + $table->foreign('penduduk_id')->references('id')->on('das_penduduk')->onDelete('cascade'); + $table->dropColumn('penduduk_id_gabungan'); + }); + } +}; diff --git a/resources/views/components/penduduk-gabungan-select.blade.php b/resources/views/components/penduduk-gabungan-select.blade.php new file mode 100644 index 0000000000..d8482a857c --- /dev/null +++ b/resources/views/components/penduduk-gabungan-select.blade.php @@ -0,0 +1,81 @@ +@props([ + 'name' => 'penduduk_id', + 'id' => 'penduduk_id_gabungan', + 'selected' => null, + 'selectedText' => null, + 'placeholder' => 'Pilih Ketua Lembaga', + 'required' => false, + 'apiServerDatabase' => null, + 'apiKeyDatabase' => null, + 'kodeKecamatan' => null, +]) + +@php + $apiServerDatabase = $apiServerDatabase ?? ($settings['api_server_database_gabungan'] ?? ''); + $apiKeyDatabase = $apiKeyDatabase ?? ($settings['api_key_database_gabungan'] ?? ''); + $kodeKecamatan = $kodeKecamatan ?? (isset($profil) ? str_replace('.', '', $profil->kecamatan_id) : ''); +@endphp + +
+ +
+ +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/data/lembaga/gabungan/create.blade.php b/resources/views/data/lembaga/gabungan/create.blade.php new file mode 100644 index 0000000000..0aad632dc9 --- /dev/null +++ b/resources/views/data/lembaga/gabungan/create.blade.php @@ -0,0 +1,37 @@ +@extends('layouts.dashboard_template') + +@section('content') +
+

+ {{ $page_title ?? 'Page Title' }} + {{ $page_description ?? '' }} +

+ +
+
+
+
+
+ + {!! html()->form('POST', route('data.lembaga.store'))->id('form-lembaga')->class('form-horizontal form-label-left')->open() !!} + @include('layouts.fragments.error_message') +
+ + @include('flash::message') + @include('data.lembaga.gabungan.form_create') + +
+ + {!! html()->form()->close() !!} +
+
+
+
+@endsection + +@include('partials.tinymce_min') diff --git a/resources/views/data/lembaga/gabungan/edit.blade.php b/resources/views/data/lembaga/gabungan/edit.blade.php new file mode 100644 index 0000000000..f95385a9bf --- /dev/null +++ b/resources/views/data/lembaga/gabungan/edit.blade.php @@ -0,0 +1,37 @@ +@extends('layouts.dashboard_template') + +@section('content') +
+

+ {{ $page_title ?? 'Page Title' }} + {{ $page_description ?? '' }} +

+ +
+
+
+
+
+ + {!! html()->form('PUT', route('data.lembaga.update', $lembaga->id))->id('form-lembaga')->class('form-horizontal form-label-left')->open() !!} + @include('layouts.fragments.error_message') +
+ + @include('flash::message') + @include('data.lembaga.gabungan.form_edit') + +
+ + {!! html()->form()->close() !!} +
+
+
+
+@endsection + +@include('partials.tinymce_min') diff --git a/resources/views/data/lembaga/gabungan/form_create.blade.php b/resources/views/data/lembaga/gabungan/form_create.blade.php new file mode 100644 index 0000000000..5c6ea8afa1 --- /dev/null +++ b/resources/views/data/lembaga/gabungan/form_create.blade.php @@ -0,0 +1,53 @@ +@include('partials.asset_select2') +
+ + +
+ {!! html()->text('nama')->value(old('nama', isset($lembaga) ? $lembaga->nama : ''))->class('form-control')->required()->placeholder('Nama Lembaga') !!} +
+
+ +
+ + +
+ {!! html()->text('kode')->value(old('kode', isset($lembaga) ? $lembaga->kode : ''))->class('form-control')->required()->placeholder('Kode Lembaga') !!} +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ {!! html()->textarea('keterangan')->value(old('keterangan', isset($lembaga) ? $lembaga->keterangan : ''))->class('form-control')->placeholder('Deskripsi Lembaga')->rows(2) !!} +
+
+ +
+ +@include('partials.asset_jqueryvalidation') \ No newline at end of file diff --git a/resources/views/data/lembaga/gabungan/form_edit.blade.php b/resources/views/data/lembaga/gabungan/form_edit.blade.php new file mode 100644 index 0000000000..fc1dfa8616 --- /dev/null +++ b/resources/views/data/lembaga/gabungan/form_edit.blade.php @@ -0,0 +1,59 @@ +@include('partials.asset_select2') +
+ + +
+ {!! html()->text('nama')->value(old('nama', isset($lembaga) ? $lembaga->nama : ''))->class('form-control')->required()->placeholder('Nama Lembaga') !!} +
+
+ +
+ + +
+ {!! html()->text('kode')->value(old('kode', isset($lembaga) ? $lembaga->kode : ''))->class('form-control')->required()->placeholder('Kode Lembaga') !!} +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ {!! html()->textarea('keterangan')->value(old('keterangan', isset($lembaga) ? $lembaga->keterangan : ''))->class('form-control')->placeholder('Deskripsi Lembaga')->rows(2) !!} +
+
+ +
+ +@include('partials.asset_jqueryvalidation') + +@push('scripts') +{!! JsValidator::formRequest('App\Http\Requests\UpdateLembagaRequest', '#form-lembaga') !!} +@endpush \ No newline at end of file diff --git a/resources/views/data/lembaga_anggota/gabungan/create.blade.php b/resources/views/data/lembaga_anggota/gabungan/create.blade.php new file mode 100644 index 0000000000..586f5bf4e8 --- /dev/null +++ b/resources/views/data/lembaga_anggota/gabungan/create.blade.php @@ -0,0 +1,41 @@ +@extends('layouts.dashboard_template') + +@section('content') +
+

+ {{ $page_title ?? 'Page Title' }} + {{ $page_description ?? '' }} +

+ +
+
+
+
+
+ + {!! html()->form()->route('data.lembaga_anggota.store', $lembaga->slug)->method('POST')->id('form-lembaga-anggota')->class('form-horizontal form-label-left')->open() !!} + @include('layouts.fragments.error_message') + +
+ + @include('flash::message') + @include('data.lembaga_anggota.gabungan.form_create') + +
+ + {!! html()->form()->close() !!} +
+
+
+
+@endsection + +@include('partials.tinymce_min') diff --git a/resources/views/data/lembaga_anggota/gabungan/edit.blade.php b/resources/views/data/lembaga_anggota/gabungan/edit.blade.php new file mode 100644 index 0000000000..62202df849 --- /dev/null +++ b/resources/views/data/lembaga_anggota/gabungan/edit.blade.php @@ -0,0 +1,48 @@ +@extends('layouts.dashboard_template') + +@section('content') +
+

+ {{ $page_title ?? 'Page Title' }} + {{ $page_description ?? '' }} +

+ +
+
+
+
+
+ + {!! html()->form( + 'PUT', + route('data.lembaga_anggota.update', [ + 'id' => $anggota->id, + 'slug' => $lembaga->slug, + ]), + )->id('form-lembaga-anggota')->class('form-horizontal form-label-left')->open() !!} + + @include('layouts.fragments.error_message') + +
+ + @include('flash::message') + @include('data.lembaga_anggota.gabungan.form_edit') + +
+ + {!! html()->form()->close() !!} +
+
+
+
+@endsection + +@include('partials.tinymce_min') diff --git a/resources/views/data/lembaga_anggota/gabungan/form_create.blade.php b/resources/views/data/lembaga_anggota/gabungan/form_create.blade.php new file mode 100644 index 0000000000..c7a3ca5a3d --- /dev/null +++ b/resources/views/data/lembaga_anggota/gabungan/form_create.blade.php @@ -0,0 +1,99 @@ +@include('partials.asset_select2') +
+ + +
+ +
+
+
+ + +
+ {!! html()->number('no_anggota')->value(old('no_anggota', isset($anggota) ? $anggota->no_anggota : ''))->class('form-control')->required()->placeholder('Nomor Anggota')->attribute('min', 1) !!} + *Pastikan nomor anggota belum pernah digunakan ! +
+
+ +
+ + +
+
+ {!! html()->select('jabatan_id', [ + 1 => 'Ketua', + 2 => 'Wakil Ketua', + 3 => 'Sekretaris', + 4 => 'Bendahara', + 5 => 'Anggota', + ])->value(old('jabatan_id', isset($anggota) ? $anggota->jabatan : ''))->placeholder('Pilih Jabatan')->class('form-control select2')->required()->style('width:100%;') !!} +
+
+
+ +
+ + +
+ {!! html()->text('no_sk_jabatan')->value(old('no_sk_jabatan', isset($anggota) ? $anggota->no_sk_jabatan : ''))->class('form-control')->placeholder('Nomor SK Jabatan') !!} +
+
+ +
+ + +
+ {!! html()->text('no_sk_pengangkatan')->value(old('no_sk_pengangkatan', isset($anggota) ? $anggota->no_sk_pengangkatan : ''))->class('form-control')->placeholder('Nomor SK Pengangkatan') !!} +
+
+ +
+ + +
+ {!! html()->date('tgl_sk_pengangkatan')->value(old('tgl_sk_pengangkatan', isset($anggota) ? $anggota->tgl_sk_pengangkatan : ''))->class('form-control')->placeholder('Tanggal SK Pengangkatan') !!} +
+
+ +
+ + +
+ {!! html()->text('no_sk_pemberhentian')->value(old('no_sk_pemberhentian', isset($anggota) ? $anggota->no_sk_pemberhentian : ''))->class('form-control')->placeholder('Nomor SK Pemberhentian') !!} +
+
+ +
+ + +
+ {!! html()->date('tgl_sk_pemberhentian')->value(old('tgl_sk_pemberhentian', isset($anggota) ? $anggota->tgl_sk_pemberhentian : ''))->class('form-control')->placeholder('Tanggal SK Pemberhentian') !!} +
+
+ +
+ + +
+ {!! html()->text('periode')->value(old('periode', isset($anggota) ? $anggota->periode : ''))->class('form-control')->placeholder('Contoh: 6 Tahun Periode Pertama (2015 s/d 2021)') !!} +
+
+ +
+ + +
+ {!! html()->textarea('keterangan')->value(old('keterangan', isset($anggota) ? $anggota->keterangan : ''))->class('form-control')->placeholder('Keterangan')->rows(2) !!} +
+
+ +
+ +@include('partials.asset_jqueryvalidation') + +@push('scripts') + {!! JsValidator::formRequest('App\Http\Requests\StoreLembagaAnggotaRequest', '#form-lembaga-anggota') !!} +@endpush diff --git a/resources/views/data/lembaga_anggota/gabungan/form_edit.blade.php b/resources/views/data/lembaga_anggota/gabungan/form_edit.blade.php new file mode 100644 index 0000000000..161c5ebb0a --- /dev/null +++ b/resources/views/data/lembaga_anggota/gabungan/form_edit.blade.php @@ -0,0 +1,101 @@ +@include('partials.asset_select2') +
+ + +
+ +
+
+
+ + +
+ {!! html()->number('no_anggota')->value(old('no_anggota', $anggota->no_anggota ?? ''))->class('form-control')->required()->placeholder('Nomor Anggota')->attribute('min', 1) !!} + *Pastikan nomor anggota belum pernah digunakan ! +
+
+ +
+ + +
+
+ {!! html()->select('jabatan_id', [ + 1 => 'Ketua', + 2 => 'Wakil Ketua', + 3 => 'Sekretaris', + 4 => 'Bendahara', + 5 => 'Anggota', + ])->value(old('jabatan_id', isset($anggota) ? $anggota->jabatan : ''))->placeholder('Pilih Jabatan')->class('form-control select2')->required()->style('width:100%;') !!} +
+
+
+ +
+ + +
+ {!! html()->text('no_sk_jabatan')->value(old('no_sk_jabatan', $anggota->no_sk_jabatan ?? ''))->class('form-control')->placeholder('Nomor SK Jabatan') !!} +
+
+ +
+ + +
+ {!! html()->text('no_sk_pengangkatan')->value(old('no_sk_pengangkatan', $anggota->no_sk_pengangkatan ?? ''))->class('form-control')->placeholder('Nomor SK Pengangkatan') !!} +
+
+ +
+ + +
+ {!! html()->date('tgl_sk_pengangkatan')->value(old('tgl_sk_pengangkatan', $anggota->tgl_sk_pengangkatan ?? ''))->class('form-control')->placeholder('Tanggal SK Pengangkatan') !!} +
+
+ +
+ + +
+ {!! html()->text('no_sk_pemberhentian')->value(old('no_sk_pemberhentian', $anggota->no_sk_pemberhentian ?? ''))->class('form-control')->placeholder('Nomor SK Pemberhentian') !!} +
+
+ +
+ + +
+ {!! html()->date('tgl_sk_pemberhentian')->value(old('tgl_sk_pemberhentian', $anggota->tgl_sk_pemberhentian ?? ''))->class('form-control')->placeholder('Tanggal SK Pemberhentian') !!} +
+
+ +
+ + +
+ {!! html()->text('periode')->value(old('periode', $anggota->periode ?? ''))->class('form-control')->placeholder('Contoh: 6 Tahun Periode Pertama (2015 s/d 2021)') !!} +
+
+ +
+ + +
+ {!! html()->textarea('keterangan')->value(old('keterangan', $anggota->keterangan ?? ''))->class('form-control')->placeholder('Keterangan')->rows(2) !!} +
+
+ +
+ +@include('partials.asset_jqueryvalidation') + +@push('scripts') + {!! JsValidator::formRequest('App\Http\Requests\UpdateLembagaAnggotaRequest', '#form-lembaga-anggota') !!} +@endpush diff --git a/resources/views/data/lembaga_anggota/index.blade.php b/resources/views/data/lembaga_anggota/index.blade.php index a211db23d3..f57245fa87 100644 --- a/resources/views/data/lembaga_anggota/index.blade.php +++ b/resources/views/data/lembaga_anggota/index.blade.php @@ -26,7 +26,7 @@
@include('forms.btn-social', [ - 'create_url' => auth()->user()->can('access.data.lembaga_anggota.create') ? route('data.lembaga_anggota.create', $lembaga->slug) : null, + 'create_url' => auth()->user()->can('access.data.lembaga.create') ? route('data.lembaga_anggota.create', $lembaga->slug) : null, ])
@@ -47,7 +47,7 @@
- + @@ -102,7 +102,6 @@ processing: true, serverSide: false, ordering: false, - scrollX: true, ajax: "{!! route('data.lembaga_anggota.getdata', $lembaga->slug) !!}", columns: [{ data: 'aksi', diff --git a/tests/Feature/LembagaControllerTest.php b/tests/Feature/LembagaControllerTest.php new file mode 100644 index 0000000000..64d0010561 --- /dev/null +++ b/tests/Feature/LembagaControllerTest.php @@ -0,0 +1,192 @@ +withViewErrors([]); + $this->withoutMiddleware([ + \App\Http\Middleware\Authenticate::class, + \Spatie\Permission\Middleware\PermissionMiddleware::class, + \Spatie\Permission\Middleware\RoleMiddleware::class, + \App\Http\Middleware\CompleteProfile::class, + \App\Http\Middleware\GlobalShareMiddleware::class, + ]); +}); + +function getLembagaPenduduk() +{ + $penduduk = Penduduk::inRandomOrder()->first(); + if (!$penduduk) { + Penduduk::factory()->create(); + $penduduk = Penduduk::inRandomOrder()->first(); + } + return $penduduk; +} + +function getLembagaKategori() +{ + $kategori = KategoriLembaga::inRandomOrder()->first(); + if (!$kategori) { + KategoriLembaga::factory()->create(); + $kategori = KategoriLembaga::inRandomOrder()->first(); + } + return $kategori; +} + +test('it can show create lembaga page (local mode)', function () { + $penduduk = getLembagaPenduduk(); + $kategori = getLembagaKategori(); + + SettingAplikasi::updateOrCreate( + ['key' => 'sinkronisasi_database_gabungan'], + ['value' => '0'] + ); + + $response = get(route('data.lembaga.create')); + + $response->assertStatus(200); + $response->assertSee('Tambah Lembaga'); + $response->assertSee('penduduk_id'); +}); + +test('it can show create lembaga page (gabungan mode)', function () { + SettingAplikasi::updateOrCreate( + ['key' => 'sinkronisasi_database_gabungan'], + ['value' => '1'] + ); + SettingAplikasi::updateOrCreate( + ['key' => 'api_server_database_gabungan'], + ['value' => 'http://localhost:8000'] + ); + SettingAplikasi::updateOrCreate( + ['key' => 'api_key_database_gabungan'], + ['value' => 'test-key'] + ); + + $response = get(route('data.lembaga.create')); + + $response->assertStatus(200); + $response->assertSee('Tambah Lembaga'); + $response->assertSee('penduduk_id_gabungan'); +}); + +test('it can show edit lembaga page (gabungan mode)', function () { + SettingAplikasi::updateOrCreate( + ['key' => 'sinkronisasi_database_gabungan'], + ['value' => '1'] + ); + SettingAplikasi::updateOrCreate( + ['key' => 'api_server_database_gabungan'], + ['value' => 'http://localhost:8000'] + ); + SettingAplikasi::updateOrCreate( + ['key' => 'api_key_database_gabungan'], + ['value' => 'test-key'] + ); + + $penduduk = getLembagaPenduduk(); + $kategori = getLembagaKategori(); + $lembaga = Lembaga::factory()->create([ + 'lembaga_kategori_id' => $kategori->id, + 'penduduk_id' => $penduduk->id, + ]); + + $response = get(route('data.lembaga.edit', $lembaga->id)); + + $response->assertStatus(200); + $response->assertSee('Ubah Lembaga'); + $response->assertSee('penduduk_id_gabungan'); +}); + +test('it can create lembaga in gabungan mode with integer penduduk_id', function () { + SettingAplikasi::updateOrCreate( + ['key' => 'sinkronisasi_database_gabungan'], + ['value' => '1'] + ); + + $penduduk = getLembagaPenduduk(); + $kategori = getLembagaKategori(); + + $response = post(route('data.lembaga.store'), [ + 'nama' => 'Lembaga Test Gabungan', + 'kode' => 'LTG-' . uniqid(), + 'lembaga_kategori_id' => $kategori->id, + 'penduduk_id_gabungan' => $penduduk->id, + ]); + + $response->assertRedirect(route('data.lembaga.index')); + $this->assertDatabaseHas('das_lembaga', [ + 'nama' => 'Lembaga Test Gabungan', + 'lembaga_kategori_id' => $kategori->id, + 'penduduk_id_gabungan' => $penduduk->id, + ]); +}); + +test('it can update lembaga in gabungan mode with integer penduduk_id', function () { + SettingAplikasi::updateOrCreate( + ['key' => 'sinkronisasi_database_gabungan'], + ['value' => '1'] + ); + + $penduduk = getLembagaPenduduk(); + $kategori = getLembagaKategori(); + $lembaga = Lembaga::factory()->create([ + 'lembaga_kategori_id' => $kategori->id, + 'penduduk_id_gabungan' => $penduduk->id, + ]); + + $newPenduduk = getLembagaPenduduk(); + + $response = put(route('data.lembaga.update', $lembaga->id), [ + 'nama' => 'Lembaga Updated Gabungan', + 'kode' => $lembaga->kode, + 'lembaga_kategori_id' => $kategori->id, + 'penduduk_id_gabungan' => $newPenduduk->id, + ]); + + $response->assertRedirect(route('data.lembaga.index')); + $this->assertDatabaseHas('das_lembaga', [ + 'id' => $lembaga->id, + 'nama' => 'Lembaga Updated Gabungan', + 'penduduk_id_gabungan' => $newPenduduk->id, + ]); +}); From 301f2868a629b7b6594c0deee82e1f4ce0bf9d02 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 8 Sep 2026 08:30:11 +0700 Subject: [PATCH 18/52] perbaikan test --- tests/Feature/LembagaAnggotaControllerTest.php | 16 ++++++++-------- tests/Feature/LembagaControllerTest.php | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/Feature/LembagaAnggotaControllerTest.php b/tests/Feature/LembagaAnggotaControllerTest.php index 828c073d00..4ede05554d 100644 --- a/tests/Feature/LembagaAnggotaControllerTest.php +++ b/tests/Feature/LembagaAnggotaControllerTest.php @@ -60,7 +60,7 @@ ); }); -function getLembagaPenduduk() +function getLembagaPendudukAnggota() { $penduduk = Penduduk::inRandomOrder()->first(); if (!$penduduk) { @@ -70,7 +70,7 @@ function getLembagaPenduduk() return $penduduk; } -function getLembagaKategori() +function getLembagaKategoriAnggota() { $kategori = KategoriLembaga::inRandomOrder()->first(); if (!$kategori) { @@ -81,8 +81,8 @@ function getLembagaKategori() } test('it can create lembaga anggota', function () { - $penduduk = getLembagaPenduduk(); - $kategori = getLembagaKategori(); + $penduduk = getLembagaPendudukAnggota(); + $kategori = getLembagaKategoriAnggota(); $lembaga = Lembaga::factory()->create([ 'lembaga_kategori_id' => $kategori->id, 'penduduk_id' => $penduduk->id, @@ -110,8 +110,8 @@ function getLembagaKategori() }); test('it can update lembaga anggota', function () { - $penduduk = getLembagaPenduduk(); - $kategori = getLembagaKategori(); + $penduduk = getLembagaPendudukAnggota(); + $kategori = getLembagaKategoriAnggota(); $lembaga = Lembaga::factory()->create([ 'lembaga_kategori_id' => $kategori->id, 'penduduk_id' => $penduduk->id, @@ -136,8 +136,8 @@ function getLembagaKategori() }); test('it can delete lembaga anggota', function () { - $penduduk = getLembagaPenduduk(); - $kategori = getLembagaKategori(); + $penduduk = getLembagaPendudukAnggota(); + $kategori = getLembagaKategoriAnggota(); $lembaga = Lembaga::factory()->create([ 'lembaga_kategori_id' => $kategori->id, 'penduduk_id' => $penduduk->id, diff --git a/tests/Feature/LembagaControllerTest.php b/tests/Feature/LembagaControllerTest.php index 64d0010561..50df21900e 100644 --- a/tests/Feature/LembagaControllerTest.php +++ b/tests/Feature/LembagaControllerTest.php @@ -34,6 +34,7 @@ use App\Models\Penduduk; use App\Models\SettingAplikasi; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\Http; use function Pest\Laravel\get; use function Pest\Laravel\post; @@ -123,6 +124,20 @@ function getLembagaKategori() ['value' => 'test-key'] ); + Http::fake([ + 'http://localhost:8000/*' => Http::response([ + 'data' => [ + [ + 'id' => 1, + 'attributes' => [ + 'nama' => 'John Doe', + 'nik' => '1234567890123456', + ], + ], + ], + ], 200), + ]); + $penduduk = getLembagaPenduduk(); $kategori = getLembagaKategori(); $lembaga = Lembaga::factory()->create([ From 04c16b41672c2453355e72bfa21feec219ca43c4 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 8 Sep 2026 08:43:27 +0700 Subject: [PATCH 19/52] perbaiki test --- tests/Feature/LembagaControllerTest.php | 33 ++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/Feature/LembagaControllerTest.php b/tests/Feature/LembagaControllerTest.php index 50df21900e..0141b78cb0 100644 --- a/tests/Feature/LembagaControllerTest.php +++ b/tests/Feature/LembagaControllerTest.php @@ -103,6 +103,23 @@ function getLembagaKategori() ['value' => 'test-key'] ); + Http::fake([ + 'http://localhost:8000/api/v1/wilayah/desa*' => Http::response([ + 'data' => [ + [ + 'id' => 1, + 'attributes' => [ + 'kode_desa' => '5102060001', + 'nama_desa' => 'Desa Test', + 'sebutan_desa' => 'Desa', + 'path' => null, + ], + ], + ], + ], 200), + 'http://localhost:8000/*' => Http::response(['data' => []], 200), + ]); + $response = get(route('data.lembaga.create')); $response->assertStatus(200); @@ -125,7 +142,20 @@ function getLembagaKategori() ); Http::fake([ - 'http://localhost:8000/*' => Http::response([ + 'http://localhost:8000/api/v1/wilayah/desa*' => Http::response([ + 'data' => [ + [ + 'id' => 1, + 'attributes' => [ + 'kode_desa' => '5102060001', + 'nama_desa' => 'Desa Test', + 'sebutan_desa' => 'Desa', + 'path' => null, + ], + ], + ], + ], 200), + 'http://localhost:8000/api/v1/opendk/sync-penduduk-opendk*' => Http::response([ 'data' => [ [ 'id' => 1, @@ -136,6 +166,7 @@ function getLembagaKategori() ], ], ], 200), + 'http://localhost:8000/*' => Http::response(['data' => []], 200), ]); $penduduk = getLembagaPenduduk(); From 22d97d598d5085c182bf387a74dd4258f585a91b Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 8 Sep 2026 09:41:23 +0700 Subject: [PATCH 20/52] perbaikan validasi nik pada database gabungan --- .../FrontEnd/SistemKomplainController.php | 34 ++--- .../FrontEnd/SistemKomplainRequest.php | 118 ++++++++++++++++++ app/Services/BaseApiService.php | 44 +++++-- app/Services/PendudukService.php | 23 ++-- 4 files changed, 165 insertions(+), 54 deletions(-) create mode 100644 app/Http/Requests/FrontEnd/SistemKomplainRequest.php diff --git a/app/Http/Controllers/FrontEnd/SistemKomplainController.php b/app/Http/Controllers/FrontEnd/SistemKomplainController.php index 052795c816..a653b4c976 100644 --- a/app/Http/Controllers/FrontEnd/SistemKomplainController.php +++ b/app/Http/Controllers/FrontEnd/SistemKomplainController.php @@ -31,15 +31,14 @@ namespace App\Http\Controllers\FrontEnd; +use App\Http\Controllers\FrontEndController; +use App\Http\Requests\FrontEnd\SistemKomplainRequest; +use App\Models\JawabKomplain; use App\Models\Komplain; use App\Models\Penduduk; +use App\Services\PendudukService; use Illuminate\Http\Request; -use App\Models\JawabKomplain; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Validator; -use App\Http\Controllers\FrontEndController; -use App\Rules\ValidasiNikRule; -use App\Services\PendudukService; use Illuminate\Support\Facades\Log; class SistemKomplainController extends FrontEndController @@ -100,34 +99,15 @@ public function tracking(Request $request) } } - public function store(Request $request) + public function store(SistemKomplainRequest $request) { try { - $maxRule = \App\Services\FileUploadService::isLimitEnabled() ? '|max:1024' : ''; - $validator = Validator::make($request->all(), [ - 'nik' => ['required', 'numeric', new ValidasiNikRule($request->input('tanggal_lahir'))], - 'judul' => 'required|string|max:255', - 'kategori' => 'required', - 'laporan' => 'required|string', - 'captcha' => 'required|captcha', - 'tanggal_lahir' => 'required|date', - 'lampiran1' => 'file|mimes:jpeg,png,jpg,gif,svg' . $maxRule . '|valid_file', - 'lampiran2' => 'file|mimes:jpeg,png,jpg,gif,svg' . $maxRule . '|valid_file', - 'lampiran3' => 'file|mimes:jpeg,png,jpg,gif,svg' . $maxRule . '|valid_file', - 'lampiran4' => 'file|mimes:jpeg,png,jpg,gif,svg' . $maxRule . '|valid_file', - ], [ - 'captcha.captcha' => 'Invalid captcha code.', - ]); - - if ($validator->fails()) { - return back()->withInput()->with('error', 'Komplain gagal dikirim!')->withErrors($validator); - } $komplain = new Komplain($request->all()); $penduduk = $this->isDatabaseGabungan() ? (new PendudukService)->cekPendudukNikTanggalLahir($request->input('nik'), $request->input('tanggal_lahir')) : Penduduk::where('nik', $komplain->nik)->first(); - + $komplain->komplain_id = Komplain::generateID(); $komplain->slug = str_slug($komplain->judul) . '-' . $komplain->komplain_id; $komplain->status = 'REVIEW'; @@ -211,6 +191,7 @@ public function store(Request $request) * Display the specified resource. * * @param int slug + * * @return Response */ public function show($slug) @@ -292,5 +273,4 @@ public function getJawabans(Request $request) return view('pages.komplain.jawabans', compact('jawabans', 'komplain'))->render(); } - } diff --git a/app/Http/Requests/FrontEnd/SistemKomplainRequest.php b/app/Http/Requests/FrontEnd/SistemKomplainRequest.php new file mode 100644 index 0000000000..a2d0fe7b28 --- /dev/null +++ b/app/Http/Requests/FrontEnd/SistemKomplainRequest.php @@ -0,0 +1,118 @@ + ['required', 'numeric', $this->nikRule()], + 'judul' => 'required|string|max:255', + 'kategori' => 'required', + 'laporan' => 'required|string', + 'tanggal_lahir' => 'required|date', + 'captcha' => 'required|captcha', + 'lampiran1' => 'file|mimes:jpeg,png,jpg,gif,svg' . $maxRule . '|valid_file', + 'lampiran2' => 'file|mimes:jpeg,png,jpg,gif,svg' . $maxRule . '|valid_file', + 'lampiran3' => 'file|mimes:jpeg,png,jpg,gif,svg' . $maxRule . '|valid_file', + 'lampiran4' => 'file|mimes:jpeg,png,jpg,gif,svg' . $maxRule . '|valid_file', + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages() + { + return [ + 'captcha.captcha' => 'Invalid captcha code.', + ]; + } + + /** + * Determine if penduduk database gabungan is active. + * + * @return bool + */ + protected function isDatabaseGabungan() + { + return SettingAplikasi::where('key', 'sinkronisasi_database_gabungan')->value('value') === '1'; + } + + /** + * NIK validation rule. + * + * When database gabungan is active, validate against the penduduk service, + * otherwise validate against the local penduduk table. + */ + protected function nikRule() + { + return function ($attribute, $value, $fail) { + $tanggalLahir = $this->input('tanggal_lahir'); + + if ($this->isDatabaseGabungan()) { + $valid = (new PendudukService)->cekPendudukNikTanggalLahir($value, $tanggalLahir); + } else { + $valid = Penduduk::where('nik', $value)->where('tanggal_lahir', $tanggalLahir)->exists(); + } + + if (!$valid) { + $fail('NIK tidak ditemukan atau tidak sesuai dengan tanggal lahir lagi.'); + } + }; + } +} diff --git a/app/Services/BaseApiService.php b/app/Services/BaseApiService.php index c43f092e57..0da1173019 100644 --- a/app/Services/BaseApiService.php +++ b/app/Services/BaseApiService.php @@ -5,20 +5,25 @@ use App\Models\SettingAplikasi; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Session; class BaseApiService { protected $settings; + protected $useDatabaseGabungan; + protected $header; + protected $baseUrl; + protected $kodeKecamatan; + private $fullResponse = false; + public function __construct() { - $this->settings = SettingAplikasi::whereIn('key', ['api_server_database_gabungan', 'api_key_database_gabungan', 'sinkronisasi_database_gabungan'])->pluck('value', 'key'); - + $this->settings = SettingAplikasi::whereIn('key', ['api_server_database_gabungan', 'api_key_database_gabungan', 'sinkronisasi_database_gabungan'])->pluck('value', 'key'); + // Allow mock via config for testing if (config()->has('api_server_database_gabungan')) { $this->settings['api_server_database_gabungan'] = config('api_server_database_gabungan'); @@ -37,30 +42,30 @@ public function __construct() 'Authorization' => 'Bearer ' . ($this->settings['api_key_database_gabungan'] ?? ''), ]; $this->baseUrl = !empty($this->settings['api_server_database_gabungan']) ? $this->settings['api_server_database_gabungan'] : 'http://localhost'; - $this->kodeKecamatan = str_replace('.','',config('profil.kecamatan_id')); + $this->kodeKecamatan = str_replace('.','',config('profil.kecamatan_id')); } /** - * General API Call Method + * General API Call Method. */ - protected function apiRequest(string $endpoint, array $params = []) - { + protected function apiRequest(string $endpoint, array $params = [], string $method = 'GET') + { try { // Buat permintaan API dengan Header dan Parameter - $response = Http::withHeaders($this->header)->get($this->baseUrl . $endpoint, $params); + $response = Http::withHeaders($this->header)->{$this->resolveMethod($method)}($this->baseUrl . $endpoint, $params); session()->forget('error_api'); $jsonResponse = $response->json(); - + if($this->isFullResponse()) { // Jika full response, kembalikan seluruh response return $jsonResponse; } - + // Return JSON hasil, cek apakah ada key 'data', jika tidak ada kembalikan seluruh response if (isset($jsonResponse['data'])) { return $jsonResponse['data']; } - + return $jsonResponse; } catch (\Exception $e) { if (app()->environment('testing')) { @@ -70,10 +75,10 @@ protected function apiRequest(string $endpoint, array $params = []) Log::error('Failed get data in '.__FILE__.' function '.__METHOD__.' '. $e->getMessage()); } return []; - } + } /** - * General API Call Method + * General API Call Method. */ protected function apiRequestLengkap(string $endpoint, array $params = []) { @@ -81,11 +86,24 @@ protected function apiRequestLengkap(string $endpoint, array $params = []) return $this->apiRequest($endpoint, $params); } + /** + * General API Call Method with POST. + */ + protected function apiRequestPost(string $endpoint, array $params = []) + { + return $this->apiRequest($endpoint, $params, 'POST'); + } + protected function useDatabaseGabungan() { return ($this->settings['sinkronisasi_database_gabungan'] ?? null) === '1'; } + private function resolveMethod(string $method): string + { + return strtolower($method); + } + private function setFullResponse(bool $fullResponse) { $this->fullResponse = $fullResponse; diff --git a/app/Services/PendudukService.php b/app/Services/PendudukService.php index 8372a9959f..591d14d3b5 100644 --- a/app/Services/PendudukService.php +++ b/app/Services/PendudukService.php @@ -3,14 +3,12 @@ namespace App\Services; use App\Models\Penduduk; -use App\Models\SettingAplikasi; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class PendudukService extends BaseApiService { /** - * Get Unique Desa + * Get Unique Desa. */ public function jumlahPenduduk(array $filters = []) { @@ -33,7 +31,7 @@ public function jumlahPenduduk(array $filters = []) } /** - * Get Unique Desa + * Get Unique Desa. */ public function desa(array $filters = []) { @@ -59,7 +57,7 @@ public function desa(array $filters = []) } /** - * Export Data Penduduk + * Export Data Penduduk. */ public function exportPenduduk($size, $number, $search) { @@ -110,23 +108,20 @@ public function detailPenduduk($id) } /** - * Export Data Penduduk + * Export Data Penduduk. */ - public function cekPendudukNikTanggalLahir($nik, $tgl_lhr = null) + public function cekPendudukNikTanggalLahir(string $nik, $tgl_lhr = null) { try { - $baseUrl = $this->settings['api_server_database_gabungan']; - - $response = Http::post($baseUrl . '/api/v1/opendk/penduduk-nik-tanggalahir', [ - 'kode_kecamatan' => str_replace('.', '', config('profil.kecamatan_id')), + $data = $this->apiRequestPost('/api/v1/opendk/penduduk-nik-tanggalahir', [ + 'kode_kecamatan' => $this->kodeKecamatan, 'nik' => $nik, 'tanggallahir' => $tgl_lhr, ]); - if ($response->successful() && $response->json('data')) { - $pendudukData = $response->json('data'); + if ($data) { $penduduk = new Penduduk(); - $penduduk->forceFill($pendudukData); + $penduduk->forceFill($data); return $penduduk; } From 6f6849c3736440db8fbf95700354ff2d761afa41 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 8 Sep 2026 09:46:31 +0700 Subject: [PATCH 21/52] perbaikan typo --- app/Http/Requests/FrontEnd/SistemKomplainRequest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Requests/FrontEnd/SistemKomplainRequest.php b/app/Http/Requests/FrontEnd/SistemKomplainRequest.php index a2d0fe7b28..8576ccc69c 100644 --- a/app/Http/Requests/FrontEnd/SistemKomplainRequest.php +++ b/app/Http/Requests/FrontEnd/SistemKomplainRequest.php @@ -111,7 +111,7 @@ protected function nikRule() } if (!$valid) { - $fail('NIK tidak ditemukan atau tidak sesuai dengan tanggal lahir lagi.'); + $fail('NIK tidak ditemukan atau tidak sesuai dengan tanggal lahir.'); } }; } From 9f5df4724896d00e9a1cd8225ca0f84e5c7030bc Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 8 Sep 2026 09:52:44 +0700 Subject: [PATCH 22/52] perbaikan test --- app/Services/BaseApiService.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/Services/BaseApiService.php b/app/Services/BaseApiService.php index 0da1173019..13ac15f86d 100644 --- a/app/Services/BaseApiService.php +++ b/app/Services/BaseApiService.php @@ -54,6 +54,12 @@ protected function apiRequest(string $endpoint, array $params = [], string $meth // Buat permintaan API dengan Header dan Parameter $response = Http::withHeaders($this->header)->{$this->resolveMethod($method)}($this->baseUrl . $endpoint, $params); session()->forget('error_api'); + + // Jika response gagal (non-2xx), kembalikan array kosong + if (!$response->successful()) { + return []; + } + $jsonResponse = $response->json(); if($this->isFullResponse()) { @@ -62,7 +68,7 @@ protected function apiRequest(string $endpoint, array $params = [], string $meth } // Return JSON hasil, cek apakah ada key 'data', jika tidak ada kembalikan seluruh response - if (isset($jsonResponse['data'])) { + if (array_key_exists('data', $jsonResponse)) { return $jsonResponse['data']; } From b92583f58ee9ded415188661784e9e44a684d67b Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 8 Sep 2026 10:14:13 +0700 Subject: [PATCH 23/52] perbaikan kirim pesan --- .../Controllers/Pesan/PesanController.php | 74 +++++++++---------- app/Http/Requests/FilterPesanRequest.php | 51 +++++++++++++ app/Http/Requests/ReplyPesanRequest.php | 50 +++++++++++++ app/Http/Requests/SetArsipPesanRequest.php | 49 ++++++++++++ .../SetMultipleArsipPesanStatusRequest.php | 49 ++++++++++++ .../SetMultipleReadPesanStatusRequest.php | 49 ++++++++++++ .../Requests/StoreComposePesanRequest.php | 56 ++++++++++++++ app/Services/DesaService.php | 4 +- resources/views/pesan/compose_pesan.blade.php | 10 +-- 9 files changed, 344 insertions(+), 48 deletions(-) create mode 100644 app/Http/Requests/FilterPesanRequest.php create mode 100644 app/Http/Requests/ReplyPesanRequest.php create mode 100644 app/Http/Requests/SetArsipPesanRequest.php create mode 100644 app/Http/Requests/SetMultipleArsipPesanStatusRequest.php create mode 100644 app/Http/Requests/SetMultipleReadPesanStatusRequest.php create mode 100644 app/Http/Requests/StoreComposePesanRequest.php diff --git a/app/Http/Controllers/Pesan/PesanController.php b/app/Http/Controllers/Pesan/PesanController.php index ea7046425a..3724632c46 100644 --- a/app/Http/Controllers/Pesan/PesanController.php +++ b/app/Http/Controllers/Pesan/PesanController.php @@ -32,16 +32,23 @@ namespace App\Http\Controllers\Pesan; use App\Http\Controllers\Controller; +use App\Http\Requests\FilterPesanRequest; +use App\Http\Requests\ReplyPesanRequest; +use App\Http\Requests\SetArsipPesanRequest; +use App\Http\Requests\SetMultipleArsipPesanStatusRequest; +use App\Http\Requests\SetMultipleReadPesanStatusRequest; +use App\Http\Requests\StoreComposePesanRequest; use App\Models\Pesan; use App\Models\PesanDetail; use App\Services\DesaService; -use Illuminate\Http\Request; +use Illuminate\Contracts\View\View; +use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\DB; use Stevebauman\Purify\Facades\Purify; class PesanController extends Controller { - public function index(Request $request) + public function index(FilterPesanRequest $request): View { $data = collect([]); $data->put('page_title', 'Pesan'); @@ -72,31 +79,14 @@ public function index(Request $request) }) ->paginate(Pesan::PER_PAGE); - $list_desa = (new DesaService())->listDesa(); + $list_desa = (new DesaService())->listDesa(); $data->put('list_pesan', $pesan); $data->put('list_desa', $list_desa); return view('pesan.masuk.index', $data->all()); } - protected function loadCounter() - { - $counter_unread = Pesan::where([ - 'jenis' => Pesan::PESAN_MASUK, - 'diarsipkan' => Pesan::NON_ARSIP, - 'sudah_dibaca' => Pesan::BELUM_DIBACA])->count(); - $counter_unread_keluar = Pesan::where([ - 'jenis' => Pesan::PESAN_KELUAR, - 'diarsipkan' => Pesan::NON_ARSIP, - 'sudah_dibaca' => Pesan::BELUM_DIBACA])->count(); - - return [ - 'counter_unread' => $counter_unread, - 'counter_unread_keluar' => $counter_unread_keluar, - ]; - } - - public function loadPesanKeluar(Request $request) + public function loadPesanKeluar(FilterPesanRequest $request): View { $data = collect([]); $data->put('desa_id', null); @@ -128,7 +118,7 @@ public function loadPesanKeluar(Request $request) return view('pesan.keluar.index', $data->all()); } - public function loadPesanArsip(Request $request) + public function loadPesanArsip(FilterPesanRequest $request): View { $data = collect([]); $data->put('desa_id', null); @@ -159,7 +149,7 @@ public function loadPesanArsip(Request $request) return view('pesan.arsip.index', $data->all()); } - public function readPesan($id_pesan) + public function readPesan(int $id_pesan): View { $pesan = Pesan::findOrFail($id_pesan); if ($pesan->sudah_dibaca == Pesan::BELUM_DIBACA) { @@ -176,7 +166,7 @@ public function readPesan($id_pesan) return view('pesan.read_pesan', $data->all()); } - public function composePesan() + public function composePesan(): View { $data = collect([]); $data->put('page_title', 'Buat Pesan'); @@ -188,18 +178,9 @@ public function composePesan() return view('pesan.compose_pesan', $data->all()); } - /** - * @throws \Illuminate\Validation\ValidationException - */ - public function storeComposePesan(Request $request) + public function storeComposePesan(StoreComposePesanRequest $request): RedirectResponse { try { - $this->validate($request, [ - 'judul' => 'required', - 'das_data_desa_id' => 'required|'.(!$this->isDatabaseGabungan() ? 'exists:das_data_desa,desa_id' : ''), - 'text' => 'required', - ]); - DB::transaction(function () use ($request) { $desa = (new DesaService())->getDesaByKode($request->get('das_data_desa_id')); $id = Pesan::create([ @@ -224,7 +205,7 @@ public function storeComposePesan(Request $request) } } - public function setArsipPesan(Request $request) + public function setArsipPesan(SetArsipPesanRequest $request): RedirectResponse { $pesan = Pesan::findOrFail($request->get('id')); $pesan->diarsipkan = Pesan::MASUK_ARSIP; @@ -235,7 +216,7 @@ public function setArsipPesan(Request $request) return back()->withInput()->with('error', 'Pesan gagal diarsipkan!'); } - public function setMultipleReadPesanStatus(Request $request) + public function setMultipleReadPesanStatus(SetMultipleReadPesanStatusRequest $request): RedirectResponse { $array = json_decode($request->get('array_id')); $pesan = Pesan::whereIn('id', $array)->update([ @@ -249,7 +230,7 @@ public function setMultipleReadPesanStatus(Request $request) return back()->withInput()->with('error', 'Pesan gagal ditandai!'); } - public function setMultipleArsipPesanStatus(Request $request) + public function setMultipleArsipPesanStatus(SetMultipleArsipPesanStatusRequest $request): RedirectResponse { $array = json_decode($request->get('array_id')); $pesan = Pesan::whereIn('id', $array)->update([ @@ -263,7 +244,7 @@ public function setMultipleArsipPesanStatus(Request $request) return back()->withInput()->with('error', 'Pesan gagal diarsipkan!'); } - public function replyPesan(Request $request) + public function replyPesan(ReplyPesanRequest $request): RedirectResponse { $pesan = PesanDetail::create([ 'pesan_id' => $request->get('id'), @@ -278,4 +259,21 @@ public function replyPesan(Request $request) return back()->withInput()->with('error', 'Pesan gagal dikirim!'); } + + protected function loadCounter(): array + { + $counter_unread = Pesan::where([ + 'jenis' => Pesan::PESAN_MASUK, + 'diarsipkan' => Pesan::NON_ARSIP, + 'sudah_dibaca' => Pesan::BELUM_DIBACA])->count(); + $counter_unread_keluar = Pesan::where([ + 'jenis' => Pesan::PESAN_KELUAR, + 'diarsipkan' => Pesan::NON_ARSIP, + 'sudah_dibaca' => Pesan::BELUM_DIBACA])->count(); + + return [ + 'counter_unread' => $counter_unread, + 'counter_unread_keluar' => $counter_unread_keluar, + ]; + } } diff --git a/app/Http/Requests/FilterPesanRequest.php b/app/Http/Requests/FilterPesanRequest.php new file mode 100644 index 0000000000..9c1b0af077 --- /dev/null +++ b/app/Http/Requests/FilterPesanRequest.php @@ -0,0 +1,51 @@ + ['nullable', 'integer'], + 'q' => ['nullable', 'string'], + 'sudahdibaca' => ['nullable', 'in:0,1'], + ]; + } +} diff --git a/app/Http/Requests/ReplyPesanRequest.php b/app/Http/Requests/ReplyPesanRequest.php new file mode 100644 index 0000000000..40345edc16 --- /dev/null +++ b/app/Http/Requests/ReplyPesanRequest.php @@ -0,0 +1,50 @@ + ['required', 'integer', 'exists:das_pesan,id'], + 'text' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/SetArsipPesanRequest.php b/app/Http/Requests/SetArsipPesanRequest.php new file mode 100644 index 0000000000..7eed898f47 --- /dev/null +++ b/app/Http/Requests/SetArsipPesanRequest.php @@ -0,0 +1,49 @@ + ['required', 'integer', 'exists:das_pesan,id'], + ]; + } +} diff --git a/app/Http/Requests/SetMultipleArsipPesanStatusRequest.php b/app/Http/Requests/SetMultipleArsipPesanStatusRequest.php new file mode 100644 index 0000000000..6e716ce60a --- /dev/null +++ b/app/Http/Requests/SetMultipleArsipPesanStatusRequest.php @@ -0,0 +1,49 @@ + ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/SetMultipleReadPesanStatusRequest.php b/app/Http/Requests/SetMultipleReadPesanStatusRequest.php new file mode 100644 index 0000000000..b0376710a8 --- /dev/null +++ b/app/Http/Requests/SetMultipleReadPesanStatusRequest.php @@ -0,0 +1,49 @@ + ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/StoreComposePesanRequest.php b/app/Http/Requests/StoreComposePesanRequest.php new file mode 100644 index 0000000000..ad1c090460 --- /dev/null +++ b/app/Http/Requests/StoreComposePesanRequest.php @@ -0,0 +1,56 @@ + ['required', 'string'], + 'das_data_desa_id' => ['required', Rule::when(! $isDatabaseGabungan, ['exists:das_data_desa,desa_id'])], + 'text' => ['required', 'string'], + ]; + } +} diff --git a/app/Services/DesaService.php b/app/Services/DesaService.php index bb227eaaf3..51ae88d4c6 100644 --- a/app/Services/DesaService.php +++ b/app/Services/DesaService.php @@ -200,9 +200,9 @@ public function jumlahDesa(array $filters = []) * Dapatkan object desa berdasarkan kode_desa tertentu * * @param string $kodeDesa - * @return collection|DataDesa|stdClass|null + * @return collection|DataDesa|\stdClass|null */ - public function getDesaByKode(string $kodeDesa): Collection|DataDesa|stdClass|null + public function getDesaByKode(string $kodeDesa): Collection|DataDesa|\stdClass|null { $listDesa = $this->listDesa(); return $listDesa->where('desa_id', $kodeDesa)->first(); diff --git a/resources/views/pesan/compose_pesan.blade.php b/resources/views/pesan/compose_pesan.blade.php index c5e39e053f..e20eac49fe 100644 --- a/resources/views/pesan/compose_pesan.blade.php +++ b/resources/views/pesan/compose_pesan.blade.php @@ -28,7 +28,7 @@
- {!! html()->select('das_data_desa_id', $list_desa->pluck('nama', 'desa_id'), null)->placeholder('pilih desa')->class('form-control')->id('list_desa')->required() !!} + @include('layouts.fragments.select-desa', ['selectAttributes' => ['required' => 'required', 'name' => 'das_data_desa_id']])
@@ -56,13 +56,7 @@ @include('partials.asset_tinymce') @include('partials.asset_select2') @push('scripts') - -@endpush + if (url && url !== '#' && url !== 'javascript:void(0)') { + openImageModal(url, title, downloadUrl, targetModal); + } + }); + + @endpush @endonce diff --git a/resources/views/components/penduduk-gabungan-select.blade.php b/resources/views/components/penduduk-gabungan-select.blade.php index d8482a857c..fcee29704e 100644 --- a/resources/views/components/penduduk-gabungan-select.blade.php +++ b/resources/views/components/penduduk-gabungan-select.blade.php @@ -17,13 +17,9 @@ @endphp
- - @if($selected) + @if ($selected) @endif @@ -78,4 +74,4 @@ class="form-control select2" }); }); -@endpush \ No newline at end of file +@endpush diff --git a/resources/views/data/aki_akb/index.blade.php b/resources/views/data/aki_akb/index.blade.php index f6ed834ed4..f251ca0e2a 100644 --- a/resources/views/data/aki_akb/index.blade.php +++ b/resources/views/data/aki_akb/index.blade.php @@ -95,11 +95,11 @@ class: 'text-center', [1, 'desc'] ] }); - + $('#list_desa').on('select2:select', function(e) { data.ajax.reload(); }); - + }); @include('forms.datatable-vertical') diff --git a/resources/views/data/data_suplemen/create.blade.php b/resources/views/data/data_suplemen/create.blade.php index a28974af98..db9bc47502 100644 --- a/resources/views/data/data_suplemen/create.blade.php +++ b/resources/views/data/data_suplemen/create.blade.php @@ -19,7 +19,7 @@ {!! html()->form('POST', route('data.data-suplemen.store'))->id('form-faq')->class( 'form-horizontal - form-label-left', + form-label-left', )->open() !!}
diff --git a/resources/views/data/data_suplemen/form.blade.php b/resources/views/data/data_suplemen/form.blade.php index 52f00f6daa..8c6fed1e8d 100644 --- a/resources/views/data/data_suplemen/form.blade.php +++ b/resources/views/data/data_suplemen/form.blade.php @@ -18,8 +18,8 @@
{!! html()->textarea('keterangan')->class('textarea')->style( 'width: 100%; height: 200px; font-size: 14px; - line-height: 18px; border: 1px solid #dddddd; padding: - 10px;', + line-height: 18px; border: 1px solid #dddddd; padding: + 10px;', )->placeholder('Keterangan')->value(old('keterangan', isset($suplemen) ? $suplemen->keterangan : '')) !!}
diff --git a/resources/views/data/data_suplemen/form_detail.blade.php b/resources/views/data/data_suplemen/form_detail.blade.php index f7a4a05410..2b833dbd02 100644 --- a/resources/views/data/data_suplemen/form_detail.blade.php +++ b/resources/views/data/data_suplemen/form_detail.blade.php @@ -50,7 +50,7 @@
diff --git a/resources/views/data/fasilitas_paud/import.blade.php b/resources/views/data/fasilitas_paud/import.blade.php index 1d9d24f9da..b28ec40633 100644 --- a/resources/views/data/fasilitas_paud/import.blade.php +++ b/resources/views/data/fasilitas_paud/import.blade.php @@ -21,7 +21,7 @@ {!! html()->form('POST', route('data.fasilitas-paud.do_import'))->id('form-import')->class( 'form-horizontal - form-label-left', + form-label-left', )->acceptsFiles()->open() !!}
@@ -43,7 +43,7 @@
- @include('layouts.fragments.select-desa', ['selectAttributes' => ['name' => 'desa_id','data-width' => '100%', 'required' => 'required']]) + @include('layouts.fragments.select-desa', ['selectAttributes' => ['name' => 'desa_id', 'data-width' => '100%', 'required' => 'required']])
@@ -103,6 +103,7 @@ + }, + error: function(xhr, status, error) { + console.error('Error loading potensi:', error); + var $container = $('#potensi-container .post.clearfix'); + $container.html('

Gagal memuat potensi. Silakan coba lagi nanti.

'); + } + }); + } + + // Load potensi on page load + loadPotensi(); + }); + @endpush diff --git a/themes/opendk/default/resources/views/pages/potensi/show.blade.php b/themes/opendk/default/resources/views/pages/potensi/show.blade.php index d20063d75c..7ea4f1477f 100644 --- a/themes/opendk/default/resources/views/pages/potensi/show.blade.php +++ b/themes/opendk/default/resources/views/pages/potensi/show.blade.php @@ -2,7 +2,7 @@ @section('content')
-
+
@@ -11,7 +11,7 @@

Loading potensi...

-
+
@@ -19,47 +19,47 @@ @endsection @push('scripts') - + }); + } + + // Load potensi detail on page load + loadPotensiDetail(); + }); + @endpush diff --git a/themes/opendk/default/resources/views/pages/profil/gabungan/letakgeografis.blade.php b/themes/opendk/default/resources/views/pages/profil/gabungan/letakgeografis.blade.php index 82dfe69824..bae0e8f825 100644 --- a/themes/opendk/default/resources/views/pages/profil/gabungan/letakgeografis.blade.php +++ b/themes/opendk/default/resources/views/pages/profil/gabungan/letakgeografis.blade.php @@ -13,8 +13,7 @@
- +
From 42f242ed9bb1b35375957e5305e41ac4f4387ff1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 1 Oct 2026 03:47:17 +0000 Subject: [PATCH 45/52] Blade Prettier Code --- resources/views/backend/event/form_edit.blade.php | 2 +- resources/views/data/data_suplemen/create.blade.php | 2 +- resources/views/data/data_suplemen/form.blade.php | 4 ++-- resources/views/data/data_suplemen/form_detail.blade.php | 2 +- resources/views/data/fasilitas_paud/import.blade.php | 2 +- resources/views/data/putus_sekolah/import.blade.php | 2 +- resources/views/data/tingkat_pendidikan/import.blade.php | 2 +- resources/views/data/toilet_sanitasi/import.blade.php | 2 +- resources/views/informasi/potensi/create.blade.php | 2 +- resources/views/informasi/prosedur/create.blade.php | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/resources/views/backend/event/form_edit.blade.php b/resources/views/backend/event/form_edit.blade.php index fe6cb00891..a1ecec2a7e 100644 --- a/resources/views/backend/event/form_edit.blade.php +++ b/resources/views/backend/event/form_edit.blade.php @@ -15,7 +15,7 @@
{!! html()->textarea('description', old('description', $event->description))->class('textarea my-editor')->placeholder('Deskripsi kegiatan')->style( 'width: 100%; height: 200px; font-size: 14px; line-height: 18px; border: 1px solid #dddddd; padding: - 10px;', + 10px;', )->required() !!}
diff --git a/resources/views/data/data_suplemen/create.blade.php b/resources/views/data/data_suplemen/create.blade.php index db9bc47502..931dbcb4e2 100644 --- a/resources/views/data/data_suplemen/create.blade.php +++ b/resources/views/data/data_suplemen/create.blade.php @@ -19,7 +19,7 @@ {!! html()->form('POST', route('data.data-suplemen.store'))->id('form-faq')->class( 'form-horizontal - form-label-left', + form-label-left', )->open() !!}
diff --git a/resources/views/data/data_suplemen/form.blade.php b/resources/views/data/data_suplemen/form.blade.php index 8c6fed1e8d..e7f7906410 100644 --- a/resources/views/data/data_suplemen/form.blade.php +++ b/resources/views/data/data_suplemen/form.blade.php @@ -18,8 +18,8 @@
{!! html()->textarea('keterangan')->class('textarea')->style( 'width: 100%; height: 200px; font-size: 14px; - line-height: 18px; border: 1px solid #dddddd; padding: - 10px;', + line-height: 18px; border: 1px solid #dddddd; padding: + 10px;', )->placeholder('Keterangan')->value(old('keterangan', isset($suplemen) ? $suplemen->keterangan : '')) !!}
diff --git a/resources/views/data/data_suplemen/form_detail.blade.php b/resources/views/data/data_suplemen/form_detail.blade.php index 2b833dbd02..59d41b9b35 100644 --- a/resources/views/data/data_suplemen/form_detail.blade.php +++ b/resources/views/data/data_suplemen/form_detail.blade.php @@ -50,7 +50,7 @@
diff --git a/resources/views/data/fasilitas_paud/import.blade.php b/resources/views/data/fasilitas_paud/import.blade.php index b28ec40633..f6f42ff63d 100644 --- a/resources/views/data/fasilitas_paud/import.blade.php +++ b/resources/views/data/fasilitas_paud/import.blade.php @@ -21,7 +21,7 @@ {!! html()->form('POST', route('data.fasilitas-paud.do_import'))->id('form-import')->class( 'form-horizontal - form-label-left', + form-label-left', )->acceptsFiles()->open() !!}
diff --git a/resources/views/data/putus_sekolah/import.blade.php b/resources/views/data/putus_sekolah/import.blade.php index 3731fe588b..46b6046506 100644 --- a/resources/views/data/putus_sekolah/import.blade.php +++ b/resources/views/data/putus_sekolah/import.blade.php @@ -20,7 +20,7 @@ {!! html()->form()->route('data.putus-sekolah.do_import')->method('POST')->id('form-import')->class( 'form-horizontal - form-label-left', + form-label-left', )->acceptsFiles()->open() !!}
diff --git a/resources/views/data/tingkat_pendidikan/import.blade.php b/resources/views/data/tingkat_pendidikan/import.blade.php index 3b8064b9e4..94fc1cb9cf 100644 --- a/resources/views/data/tingkat_pendidikan/import.blade.php +++ b/resources/views/data/tingkat_pendidikan/import.blade.php @@ -20,7 +20,7 @@ {!! html()->form('POST', route('data.tingkat-pendidikan.do_import'))->id('form-import')->class( 'form-horizontal - form-label-left', + form-label-left', )->acceptsFiles()->open() !!}
diff --git a/resources/views/data/toilet_sanitasi/import.blade.php b/resources/views/data/toilet_sanitasi/import.blade.php index c955070420..31128da43a 100644 --- a/resources/views/data/toilet_sanitasi/import.blade.php +++ b/resources/views/data/toilet_sanitasi/import.blade.php @@ -33,7 +33,7 @@ {!! html()->form('POST', route('data.toilet-sanitasi.do_import'))->id('form-import')->class( 'form-horizontal - form-label-left', + form-label-left', )->acceptsFiles()->open() !!}
diff --git a/resources/views/informasi/potensi/create.blade.php b/resources/views/informasi/potensi/create.blade.php index e5c066d261..d355945121 100644 --- a/resources/views/informasi/potensi/create.blade.php +++ b/resources/views/informasi/potensi/create.blade.php @@ -21,7 +21,7 @@ {!! html()->form('POST', route('informasi.potensi.store'))->acceptsFiles()->id('form-potensi')->class( 'form-horizontal - form-label-left', + form-label-left', )->open() !!} @if (count($errors) > 0) diff --git a/resources/views/informasi/prosedur/create.blade.php b/resources/views/informasi/prosedur/create.blade.php index bc4a1ba0e8..b964a80218 100644 --- a/resources/views/informasi/prosedur/create.blade.php +++ b/resources/views/informasi/prosedur/create.blade.php @@ -21,7 +21,7 @@ {!! html()->form('POST', route('informasi.prosedur.store'))->acceptsFiles()->id('form-prosedur')->class( 'form-horizontal - form-label-left', + form-label-left', )->open() !!} @if (count($errors) > 0) From b55bfd89292b5c855424b76efd2c44dc75f76463 Mon Sep 17 00:00:00 2001 From: ahmad afandi Date: Fri, 2 Oct 2026 08:04:54 +0700 Subject: [PATCH 46/52] Fix: validasi `valid_file` menolak PDF sah karena string generik "function" (#1765) Co-authored-by: Abah Roland --- .docker/nginx/conf.d/default.conf | 7 ++ app/Http/Middleware/SecurityHeaders.php | 11 ++ app/Providers/AppServiceProvider.php | 15 +-- app/Rules/SafeFileContent.php | 106 ++++++++++++++++++ catatan_rilis.md | 1 + tests/Arch/NginxUploadGuardTest.php | 31 ++++++ tests/Unit/SecurityHeadersTest.php | 39 +++++++ tests/Unit/ValidFileContentTest.php | 136 ++++++++++++++++++++++++ 8 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 app/Rules/SafeFileContent.php create mode 100644 tests/Arch/NginxUploadGuardTest.php create mode 100644 tests/Unit/SecurityHeadersTest.php create mode 100644 tests/Unit/ValidFileContentTest.php diff --git a/.docker/nginx/conf.d/default.conf b/.docker/nginx/conf.d/default.conf index a53cff6eff..0dcc120116 100644 --- a/.docker/nginx/conf.d/default.conf +++ b/.docker/nginx/conf.d/default.conf @@ -17,6 +17,13 @@ server { try_files $uri $uri/ /index.php?$query_string; } + # File hasil upload disajikan langsung dari storage/app/public (symlink + # public/storage) sehingga tidak melewati aplikasi. Blokir eksekusi skrip + # di jalur ini sebagai lap keamanan terakhir. + location ~* ^/storage/.*\.(php|php[0-9]?|phtml|phar|pl|py|rb|sh|bash|cgi|jsp|asp|aspx|htaccess)$ { + deny all; + } + error_page 404 /index.php; location ~ \.php$ { diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php index c7441b2932..83ba878258 100644 --- a/app/Http/Middleware/SecurityHeaders.php +++ b/app/Http/Middleware/SecurityHeaders.php @@ -47,6 +47,17 @@ public function handle($request, Closure $next) /** @var \Illuminate\Http\Response $response */ $response = $next($request); + foreach ($this->unwantedHeaders as $header) { + $response->headers->remove($header); + } + + /* + * Cegah browser menebak-nebak Content-Type dari isi file. Tanpa header ini + * file markup seperti SVG dapat dieksekusi sebagai script meskipun + * header dan nama ekstensinya tidak menyerupai HTML. + */ + $response->headers->set('X-Content-Type-Options', 'nosniff'); + if (app()->environment('production')) { $localDomain = env('APP_URL', 'http://localhost'); $urlDatabaseGabungan = config('setting.api_server_database_gabungan'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a9f28e67ed..c77edb3db7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -31,6 +31,7 @@ namespace App\Providers; +use App\Rules\SafeFileContent; use App\Services\ActivityLogService; use App\Services\CacheService; use App\Support\Collection; @@ -44,7 +45,6 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; use Illuminate\Support\ServiceProvider; @@ -244,16 +244,19 @@ protected function blade(): void /** * Validator untuk file upload (mencegah file berbahaya). + * + * Logika berada di App\Rules\SafeFileContent agar dapat diuji terpisah. */ protected function file(): void { Validator::extend('valid_file', function ($attributes, $value, $parameters) { - $contains = preg_match('/<\?php|validate($attributes, $value, function () use (&$failed) { + $failed = true; + }); + + return ! $failed; }); } diff --git a/app/Rules/SafeFileContent.php b/app/Rules/SafeFileContent.php new file mode 100644 index 0000000000..a4b33c840c --- /dev/null +++ b/app/Rules/SafeFileContent.php @@ -0,0 +1,106 @@ +isValid()) { + $this->fail($attribute, $fail); + + return; + } + + $head = $this->readHead($value); + + if ($this->matches(self::SCRIPT_SIGNATURES, $head)) { + $this->fail($attribute, $fail); + + return; + } + + $mime = $value->getMimeType() ?? ''; + + if (in_array($mime, self::MARKUP_MIMES, true) && $this->matches(self::MARKUP_DANGERS, $head)) { + $this->fail($attribute, $fail); + } + } + + /** + * Baca bagian awal file tanpa memuat seluruh isinya ke memori. + */ + protected function readHead(UploadedFile $file): string + { + $path = $file->getRealPath(); + + if ($path === false || ! is_readable($path)) { + return ''; + } + + $handle = @fopen($path, 'rb'); + + if ($handle === false) { + return ''; + } + + try { + $head = fread($handle, self::SCAN_LIMIT); + + return $head === false ? '' : $head; + } finally { + fclose($handle); + } + } + + protected function matches(string $pattern, string $content): bool + { + return $content !== '' && preg_match($pattern, $content) === 1; + } + + protected function fail(string $attribute, Closure $fail): void + { + $fail(__('validation.valid_file', ['attribute' => str_replace('_', ' ', $attribute)])); + } +} diff --git a/catatan_rilis.md b/catatan_rilis.md index 59c8dbff7a..50cfef19ad 100644 --- a/catatan_rilis.md +++ b/catatan_rilis.md @@ -21,6 +21,7 @@ Terimakasih [isi disini] yang telah berkontribusi langsung mengembangkan aplikas 10. [#1735](https://github.com/OpenSID/OpenDK/issues/1735) Periksa bagian tambah ketua lembaga ketika menggunakan API satu data. 11. [#1736](http://github.com/OpenSID/OpenDK/issues/1736) Fix Penyesuaian Validasi NIK pada Pengiriman Keluhan untuk Database Gabungan (API Satu Data). 12. [#1737](https://github.com/OpenSID/OpenDK/issues/1737) fungsi kirim pesan error 500. +13. [#1761](https://github.com/OpenSID/OpenDK/issues/1761)Perbaikan validasi valid_file menolak PDF sah karena string generik "function". #### TEKNIS diff --git a/tests/Arch/NginxUploadGuardTest.php b/tests/Arch/NginxUploadGuardTest.php new file mode 100644 index 0000000000..48c9df24b8 --- /dev/null +++ b/tests/Arch/NginxUploadGuardTest.php @@ -0,0 +1,31 @@ +toContain('^/storage/'); + expect($config)->toMatch('/location\s+~\*\s+\^\/storage\/.*phtml/'); + }); + + test('guard /storage/ ditulis sebelum location .php agar tidak ditimpa', function () use ($nginxConf) { + $config = file_get_contents($nginxConf); + + $storageGuard = strpos($config, '^/storage/'); + $phpHandler = strpos($config, 'location ~ \.php$'); + + expect($storageGuard)->not->toBeFalse(); + expect($phpHandler)->not->toBeFalse(); + expect($storageGuard)->toBeLessThan($phpHandler); + }); + + test('blokir mencakup daftar ekstensi eksekutable yang umum', function () use ($nginxConf) { + $config = file_get_contents($nginxConf); + + foreach (['php', 'phtml', 'phar', 'cgi', 'jsp', 'asp'] as $extension) { + expect($config)->toContain($extension); + } + }); +}); diff --git a/tests/Unit/SecurityHeadersTest.php b/tests/Unit/SecurityHeadersTest.php new file mode 100644 index 0000000000..f89ecd5165 --- /dev/null +++ b/tests/Unit/SecurityHeadersTest.php @@ -0,0 +1,39 @@ +handle( + Request::create('/', 'GET'), + fn () => new Response('Hello') + ); +} + +describe('SecurityHeaders nosniff', function () { + test('menambahkan X-Content-Type-Options nosniff di production', function () { + $response = handleWithSecurityHeaders('production'); + + expect($response->headers->get('X-Content-Type-Options'))->toBe('nosniff'); + }); + + test('nosniff tetap dipasang di luar production', function () { + try { + $response = handleWithSecurityHeaders('testing'); + + expect($response->headers->get('X-Content-Type-Options'))->toBe('nosniff'); + } finally { + app()['env'] = 'testing'; + } + }); + + test('tidak membocorkan versi server', function () { + $response = handleWithSecurityHeaders('production'); + + expect($response->headers->has('X-Powered-By'))->toBeFalse(); + }); +}); diff --git a/tests/Unit/ValidFileContentTest.php b/tests/Unit/ValidFileContentTest.php new file mode 100644 index 0000000000..da644a0416 --- /dev/null +++ b/tests/Unit/ValidFileContentTest.php @@ -0,0 +1,136 @@ +>endobj\n". + "2 0 obj<>endobj\n". + "3 0 obj<>endobj\n". + "stream\nBT /F1 12 Tf 72 720 Td (Function of the Quarterly Report) Tj ET\nendstream\n". + "xref\n0 4\n". + "trailer<>\n". + "startxref\n0\n%%EOF"; +} + +function svgWith(string $body): string +{ + return ''."\n". + ''. + $body. + ''; +} + +function passRule(mixed $file): bool +{ + $failed = false; + (new SafeFileContent())->validate('file', $file, function () use (&$failed) { + $failed = true; + }); + + return ! $failed; +} + +describe('valid_file content inspection', function () { + test('pdf valid yang mengandung string Function tetap lolos', function () { + $file = UploadedFile::fake()->createWithContent('renja.pdf', validPdfBinary()); + + expect(passRule($file))->toBeTrue(); + }); + + test('pdf tanpa konten berbahaya lolos', function () { + $file = UploadedFile::fake()->createWithContent('dokumen.pdf', "%PDF-1.7\n%%EOF"); + + expect(passRule($file))->toBeTrue(); + }); + + test('pdf yang menyamar dengan header pdf tetapi berisi kode php ditolak', function () { + $polyglot = "%PDF-1.4\n\n%%EOF"; + $file = UploadedFile::fake()->createWithContent('shell.pdf', $polyglot); + + expect(passRule($file))->toBeFalse(); + }); + + test('file php yang diberi ekstensi pdf ditolak', function () { + $file = UploadedFile::fake()->createWithContent('shell.pdf', ''); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg dengan tag script ditolak', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('')); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg dengan event handler onload ditolak', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('')); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg dengan javascript pada xlink href ditolak', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('x')); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg dengan foreignObject berisi html ditolak', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('')); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg bersih lolos', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('')); + + expect(passRule($file))->toBeTrue(); + }); + + test('html dengan script ditolak', function () { + $file = UploadedFile::fake()->createWithContent('page.html', ''); + + expect(passRule($file))->toBeFalse(); + }); + + test('file yang bukan uploaded file ditolak', function () { + expect(passRule('bukan-file'))->toBeFalse(); + expect(passRule(null))->toBeFalse(); + }); + + test('gambar png dan jpeg asli tetap lolos', function () { + expect(passRule(UploadedFile::fake()->image('foto.png', 20, 20)))->toBeTrue(); + expect(passRule(UploadedFile::fake()->image('foto.jpg', 20, 20)))->toBeTrue(); + }); + + test('file yang menyamar sebagai gambar tetapi berisi html ditolak', function () { + $file = UploadedFile::fake()->createWithContent('foto.png', ''); + + expect(passRule($file))->toBeFalse(); + }); +}); + +describe('valid_file rule registration', function () { + test('rule valid_file menerima pdf berisi string Function', function () { + $validator = Validator::make( + ['file_dokumen' => UploadedFile::fake()->createWithContent('renja.pdf', validPdfBinary())], + ['file_dokumen' => 'valid_file'] + ); + + expect($validator->passes())->toBeTrue(); + }); + + test('rule valid_file menolak svg berbahaya', function () { + $validator = Validator::make( + ['file_dokumen' => UploadedFile::fake()->createWithContent('logo.svg', svgWith(''))], + ['file_dokumen' => 'valid_file'] + ); + + expect($validator->fails())->toBeTrue(); + expect($validator->errors()->first('file_dokumen')) + ->toBe(trans('validation.valid_file', ['attribute' => 'file dokumen'])); + }); +}); From 7696f532e5998549222f9af25912e208c015c2e6 Mon Sep 17 00:00:00 2001 From: ahmad afandi Date: Fri, 2 Oct 2026 08:09:32 +0700 Subject: [PATCH 47/52] Fix: Menampilkan media Galeri berbasis link (Google Drive & YouTube) di Web dan Admin (#1766) Co-authored-by: Abah Roland --- app/Enums/TipeMedia.php | 90 ++++ .../Publikasi/GaleriController.php | 32 +- app/Http/Middleware/SecurityHeaders.php | 5 +- app/Http/Requests/GaleriRequest.php | 31 +- app/Models/Galeri.php | 85 +++- app/Services/MediaLinkService.php | 408 ++++++++++++++++++ app/Support/CspPolicy.php | 129 ++++++ app/ValueObjects/MediaLink.php | 90 ++++ catatan_rilis.md | 1 + ...000001_add_media_type_to_galeris_table.php | 36 ++ public/js/custom.js | 75 ++++ .../views/components/galeri-media.blade.php | 42 ++ .../views/publikasi/galeri/_form.blade.php | 54 ++- .../views/publikasi/galeri/index.blade.php | 13 +- .../galeri/parts/media-cell.blade.php | 1 + .../Api/Frontend/GaleriControllerTest.php | 120 ++++++ tests/Feature/GaleriControllerTest.php | 208 +++++++++ tests/Feature/PublikasiGaleriPublikTest.php | 65 +++ tests/Unit/Models/GaleriTest.php | 134 ++++++ tests/Unit/Services/MediaLinkServiceTest.php | 248 +++++++++++ tests/Unit/Support/CspPolicyTest.php | 53 +++ .../views/pages/publikasi/galeri.blade.php | 46 +- .../pages/publikasi/galeri_detail.blade.php | 29 +- 23 files changed, 1934 insertions(+), 61 deletions(-) create mode 100644 app/Enums/TipeMedia.php create mode 100644 app/Services/MediaLinkService.php create mode 100644 app/Support/CspPolicy.php create mode 100644 app/ValueObjects/MediaLink.php create mode 100644 database/migrations/2026_09_28_000001_add_media_type_to_galeris_table.php create mode 100644 resources/views/components/galeri-media.blade.php create mode 100644 resources/views/publikasi/galeri/parts/media-cell.blade.php create mode 100644 tests/Feature/Api/Frontend/GaleriControllerTest.php create mode 100644 tests/Feature/PublikasiGaleriPublikTest.php create mode 100644 tests/Unit/Models/GaleriTest.php create mode 100644 tests/Unit/Services/MediaLinkServiceTest.php create mode 100644 tests/Unit/Support/CspPolicyTest.php diff --git a/app/Enums/TipeMedia.php b/app/Enums/TipeMedia.php new file mode 100644 index 0000000000..ca0b7aadd2 --- /dev/null +++ b/app/Enums/TipeMedia.php @@ -0,0 +1,90 @@ + + * - video : dirender sebagai
"; + } else if (type === "video" && embed && !opts.preferNativeVideo) { + // Google Drive dan Vimeo tidak mendukung Range Request, jadi pakai preview resmi. + html = + '
' + + '" + + "
"; + } else if (type === "video" && url) { + html = + '"; + } else if (type === "image" && url) { + html = + '' + escapeAttr(label) + ''; + } else { + html = + '
' + + '' + escapeAttr(label) + '' + + "
"; + } + + return html; +} + +/** + * Tandai galeri yang berupa video/YouTube agar tema bisa menampilkan ikon putar. + */ +function isGaleriPlayable(galeri) { + var type = galeri && galeri.media_type ? galeri.media_type : "unknown"; + return type === "video" || type === "youtube"; +} + //drop down menu $(".drop-down").hover(function () { $(".dropdown-menu").addClass("display-on"); diff --git a/resources/views/components/galeri-media.blade.php b/resources/views/components/galeri-media.blade.php new file mode 100644 index 0000000000..57e426daeb --- /dev/null +++ b/resources/views/components/galeri-media.blade.php @@ -0,0 +1,42 @@ +@props(['galeri', 'variant' => 'thumb']) + +@use(App\Enums\TipeMedia) + +@php + $type = $galeri->media_type; + $url = $galeri->media_url; + $embed = $galeri->media_embed_url; + $poster = $galeri->gambar_path ?: asset('/img/no-image.png'); + $isFull = $variant === 'full'; + $isYoutube = $type === TipeMedia::Youtube; + $isVideo = in_array($type, [TipeMedia::Video, TipeMedia::Youtube], true); +@endphp + +
+ @if ($isFull && $isYoutube && $embed) + {{-- Embed YouTube memakai iframe karena YouTube tidak mengizinkan pemutaran langsung. --}} +
+ +
+ @elseif ($isFull && $type === TipeMedia::Video && $embed) + {{-- Google Drive/Vimeo tidak mendukung Range Request, jadi pakai preview resmi. --}} +
+ +
+ @elseif ($isFull && $type === TipeMedia::Video && $url) + + @elseif ($type === TipeMedia::Image && $url) + {{ $galeri->judul }} + @else + {{ $galeri->judul }} + @endif + + @if ($isVideo && !$isFull) + + + {{ $isYoutube ? 'YouTube' : 'Video' }} + + @endif +
diff --git a/resources/views/publikasi/galeri/_form.blade.php b/resources/views/publikasi/galeri/_form.blade.php index 736a6f6a50..3544c27911 100644 --- a/resources/views/publikasi/galeri/_form.blade.php +++ b/resources/views/publikasi/galeri/_form.blade.php @@ -1,3 +1,5 @@ +@use(App\Enums\TipeMedia) +
@@ -24,16 +26,54 @@
- {!! html()->text('link')->class('form-control')->value(old('link', isset($galeri) ? $galeri->link : '')) !!} + {!! html()->text('link')->class('form-control')->placeholder('https://drive.google.com/file/d/... atau https://youtube.com/watch?v=...')->value(old('link', isset($galeri) ? $galeri->link : '')) !!} + + + Mendukung tautan Google Drive, YouTube, Vimeo, serta berkas foto/video yang dihosting langsung. + @if ($errors->has('link')) {{ $errors->first('link') }} @endif
-
- +
+ - + {!! html()->select('media_type', array_merge(['' => 'Otomatis (deteksi sendiri)'], TipeMedia::selectable()))->class('form-control')->value(old('media_type', isset($galeri) ? $galeri->media_type : '')) !!} + + + Pilih Video bila berkas Google Drive berisi video, karena jenis media + tidak dapat dibaca langsung dari alamat tautannya. + + + @if ($errors->has('media_type')) + {{ $errors->first('media_type') }} + @endif +
+ +
+ + +
@@ -105,13 +145,19 @@ function cleanupObjectUrls() { function toggleUrlFields(type) { var viewUrl = $('#view-url'); + var viewMediaType = $('#view-media-type'); + var mediaPreview = $('#media-link-preview'); var image = $('#image'); if (type == 'file') { viewUrl.hide(); + viewMediaType.hide(); + mediaPreview.hide(); image.show(); } else { viewUrl.show(); + viewMediaType.show(); + mediaPreview.show(); image.hide(); } } diff --git a/resources/views/publikasi/galeri/index.blade.php b/resources/views/publikasi/galeri/index.blade.php index 79c7e07803..da71ba9224 100644 --- a/resources/views/publikasi/galeri/index.blade.php +++ b/resources/views/publikasi/galeri/index.blade.php @@ -18,16 +18,17 @@
@can('access.publikasi.galeri.create') -  Tambah +  Tambah @endcan -  Kembali ke Daftar Album +  Kembali ke Daftar Album
-
AksiFotoNama, NIP, NIKTempat, Tanggal LahirAksiFotoNama, NIP, NIKTempat, Tanggal Lahir Jenis Kelamin Agama Pangkat/Golongan JabatanStatusStatus Pendidikan Terakhir No SK Pengangkatan Tanggal SK Pengangkatan
Ketua Lembaga :{{ $lembaga->penduduk->nama }}{{ $lembaga->penduduk?->nama ?? $lembaga->pendudukGabungan?->nama ?? '-' }}
Kategori Lembaga
+
+ @@ -56,6 +57,12 @@ class: 'text-center', searchable: false, orderable: false }, + { + data: 'media', + name: 'media', + orderable: false, + searchable: false + }, { data: 'judul', name: 'judul' diff --git a/resources/views/publikasi/galeri/parts/media-cell.blade.php b/resources/views/publikasi/galeri/parts/media-cell.blade.php new file mode 100644 index 0000000000..658e0684ff --- /dev/null +++ b/resources/views/publikasi/galeri/parts/media-cell.blade.php @@ -0,0 +1 @@ + diff --git a/tests/Feature/Api/Frontend/GaleriControllerTest.php b/tests/Feature/Api/Frontend/GaleriControllerTest.php new file mode 100644 index 0000000000..8e68c22efb --- /dev/null +++ b/tests/Feature/Api/Frontend/GaleriControllerTest.php @@ -0,0 +1,120 @@ +withViewErrors([]); + $this->withoutMiddleware([ + Authenticate::class, + RoleMiddleware::class, + PermissionMiddleware::class, + CompleteProfile::class, + GlobalShareMiddleware::class, + ]); + + // Respons API di-cache; DatabaseTransactions membuat rollback pada tabel + // cache_keys sehingga cache dari test sebelumnya tidak bisa dihapus observer. + Cache::flush(); + + SettingAplikasi::updateOrCreate( + ['key' => 'sinkronisasi_database_gabungan'], + ['value' => '0'] + ); + + $this->album = Album::factory()->create([ + 'judul' => 'Album Galeri Uji', + 'slug' => 'album-galeri-uji', + ]); +}); + +function makeGaleri(array $attributes = []): Galeri +{ + return Galeri::create(array_merge([ + 'album_id' => test()->album->id, + 'judul' => 'Galeri API', + 'jenis' => 'url', + 'status' => true, + ], $attributes)); +} + +it('returns media details for a google drive video', function () { + makeGaleri([ + 'judul' => 'Video Drive', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view', + 'media_type' => 'video', + ]); + + $response = $this->getJson('/api/frontend/v1/galeri?filter[status]=1&filter[album.slug]=album-galeri-uji'); + + $response->assertStatus(200); + + $attributes = collect($response->json('data'))->firstWhere('attributes.judul', 'Video Drive')['attributes']; + + expect($attributes['media_type'])->toBe('video') + ->and($attributes['media_embed_url'])->toBe('https://drive.google.com/file/d/1AbCdEf123/preview') + ->and($attributes['media_url'])->toBe('https://drive.google.com/uc?export=download&id=1AbCdEf123'); +}); + +it('returns media details for a google drive photo', function () { + makeGaleri([ + 'judul' => 'Foto Drive', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view', + 'media_type' => 'image', + ]); + + $response = $this->getJson('/api/frontend/v1/galeri?filter[status]=1&filter[album.slug]=album-galeri-uji'); + + $attributes = collect($response->json('data'))->firstWhere('attributes.judul', 'Foto Drive')['attributes']; + + expect($attributes['media_type'])->toBe('image') + ->and($attributes['media_url'])->toBe('https://drive.google.com/uc?export=view&id=1AbCdEf123') + ->and($attributes['gambar_path'])->toBe('https://drive.google.com/uc?export=view&id=1AbCdEf123'); +}); + +it('returns an embed url and thumbnail for a youtube video', function () { + makeGaleri([ + 'judul' => 'Video YouTube', + 'link' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ]); + + $response = $this->getJson('/api/frontend/v1/galeri?filter[status]=1&filter[album.slug]=album-galeri-uji'); + + $attributes = collect($response->json('data'))->firstWhere('attributes.judul', 'Video YouTube')['attributes']; + + expect($attributes['media_type'])->toBe('youtube') + ->and($attributes['media_embed_url'])->toBe('https://www.youtube.com/embed/dQw4w9WgXcQ') + ->and($attributes['gambar_path'])->toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg'); +}); + +it('exposes the media keys for every galeri record', function () { + makeGaleri(['judul' => 'Tanpa Link', 'link' => null]); + + $response = $this->getJson('/api/frontend/v1/galeri?filter[status]=1&filter[album.slug]=album-galeri-uji'); + + $attributes = collect($response->json('data'))->firstWhere('attributes.judul', 'Tanpa Link')['attributes']; + + expect($attributes)->toHaveKeys(['media_type', 'media_url', 'media_embed_url', 'gambar_path']) + ->and($attributes['media_type'])->toBe('unknown'); +}); diff --git a/tests/Feature/GaleriControllerTest.php b/tests/Feature/GaleriControllerTest.php index 0ad0d7dee4..efc672db51 100644 --- a/tests/Feature/GaleriControllerTest.php +++ b/tests/Feature/GaleriControllerTest.php @@ -6,6 +6,7 @@ use App\Models\Galeri; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Storage; use Spatie\Permission\Middleware\PermissionMiddleware; @@ -126,6 +127,213 @@ expect($galeri->gambar)->toBeNull(); }); +test('create a galeri with google drive video link', function () { + Http::fake(['*' => Http::response('', 200, ['Content-Type' => 'video/mp4'])]); + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $data = [ + 'judul' => 'Video Kegiatan Google Drive', + 'jenis' => 'url', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view?usp=sharing', + 'status' => '1', + ]; + + $response = $this->post(route('publikasi.galeri.store'), $data); + + $response->assertRedirect(route('publikasi.galeri.index', $album->id)); + $response->assertSessionHas('success', 'Galeri berhasil disimpan!'); + + $galeri = Galeri::where('judul', 'Video Kegiatan Google Drive')->first(); + expect($galeri)->not->toBeNull() + ->and($galeri->media_type)->toBe('video') + ->and($galeri->media_embed_url)->toBe('https://drive.google.com/file/d/1AbCdEf123/preview') + ->and($galeri->media_url)->toBe('https://drive.google.com/uc?export=download&id=1AbCdEf123'); +}); + +test('create a galeri with google drive photo link', function () { + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $data = [ + 'judul' => 'Foto Kegiatan Google Drive', + 'jenis' => 'url', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view', + 'status' => '1', + ]; + + $this->post(route('publikasi.galeri.store'), $data) + ->assertSessionHas('success', 'Galeri berhasil disimpan!'); + + $galeri = Galeri::where('judul', 'Foto Kegiatan Google Drive')->first(); + expect($galeri)->not->toBeNull() + ->and($galeri->media_type)->toBe('image') + ->and($galeri->media_url)->toBe('https://drive.google.com/uc?export=view&id=1AbCdEf123'); +}); + +test('manual media type overrides the automatic detection', function () { + Http::preventStrayRequests(); + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $data = [ + 'judul' => 'Video Pilihan Manual', + 'jenis' => 'url', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view', + 'media_type' => 'video', + 'status' => '1', + ]; + + $this->post(route('publikasi.galeri.store'), $data) + ->assertSessionHas('success', 'Galeri berhasil disimpan!'); + + expect(Galeri::where('judul', 'Video Pilihan Manual')->first()->media_type)->toBe('video'); +}); + +test('rejects a link that is not a valid url', function () { + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $data = [ + 'judul' => 'Link Tidak Valid', + 'jenis' => 'url', + 'link' => 'bukan sebuah url', + 'status' => '1', + ]; + + $response = $this->post(route('publikasi.galeri.store'), $data); + + $response->assertSessionHasErrors('link'); + $this->assertDatabaseMissing('galeris', ['judul' => 'Link Tidak Valid']); +}); + +test('rejects an unsupported manual media type', function () { + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $data = [ + 'judul' => 'Tipe Media Ngawur', + 'jenis' => 'url', + 'link' => 'https://youtu.be/dQw4w9WgXcQ', + 'media_type' => 'audio', + 'status' => '1', + ]; + + $this->post(route('publikasi.galeri.store'), $data) + ->assertSessionHasErrors('media_type'); + + $this->assertDatabaseMissing('galeris', ['judul' => 'Tipe Media Ngawur']); +}); + +test('switching a galeri from file to link clears the stored image', function () { + Storage::fake('public'); + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + Storage::disk('public')->put('publikasi/galeri/lama.jpg', 'lama'); + $galeri = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Awal File', + 'gambar' => ['lama.jpg'], + 'jenis' => 'file', + 'status' => true, + ]); + + $data = [ + 'judul' => 'Galeri Jadi Link', + 'jenis' => 'url', + 'link' => 'https://youtu.be/dQw4w9WgXcQ', + 'status' => '1', + ]; + + $this->put(route('publikasi.galeri.update', $galeri->id), $data) + ->assertSessionHas('success', 'Galeri berhasil diubah!'); + + $galeri->refresh(); + expect($galeri->jenis)->toBe('url') + ->and($galeri->gambar)->toBeNull() + ->and($galeri->media_type)->toBe('youtube'); + + Storage::disk('public')->assertMissing('publikasi/galeri/lama.jpg'); +}); + +test('switching a galeri from link to file clears the stored media type', function () { + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $galeri = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Awal Link', + 'jenis' => 'url', + 'link' => 'https://youtu.be/dQw4w9WgXcQ', + 'media_type' => 'youtube', + 'status' => true, + ]); + + $data = [ + 'judul' => 'Galeri Jadi File', + 'jenis' => 'file', + 'status' => '1', + 'gambar' => [UploadedFile::fake()->image('baru.jpg', 600, 400)], + ]; + + $this->put(route('publikasi.galeri.update', $galeri->id), $data) + ->assertSessionHas('success', 'Galeri berhasil diubah!'); + + $galeri->refresh(); + expect($galeri->jenis)->toBe('file') + ->and($galeri->link)->toBeNull() + ->and($galeri->media_type)->toBe('image'); +}); + +test('display the galeri edit page with a link media preview', function () { + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + $galeri = Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri Link Video', + 'jenis' => 'url', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view', + 'media_type' => 'video', + 'status' => true, + ]); + + $response = $this->get(route('publikasi.galeri.edit', $galeri->id)); + + $response->assertStatus(200); + $response->assertViewIs('publikasi.galeri.edit'); + $response->assertSee('id="media-link-preview"', false); + $response->assertSee('https://drive.google.com/file/d/1AbCdEf123/preview', false); + $response->assertSee('id="media_type"', false); +}); + +test('galeri datatable exposes a media column that renders link media', function () { + $album = Album::factory()->create(); + Session::put('album_id', $album->id); + + Galeri::create([ + 'album_id' => $album->id, + 'judul' => 'Galeri DataTable', + 'jenis' => 'url', + 'link' => 'https://youtu.be/dQw4w9WgXcQ', + 'status' => true, + ]); + + $response = $this->getJson(route('publikasi.galeri.getdata', $album->id), [ + 'X-Requested-With' => 'XMLHttpRequest', + ]); + + $response->assertStatus(200); + $row = collect($response->json('data'))->firstWhere('judul', 'Galeri DataTable'); + + expect($row)->not->toBeNull() + ->and(data_get($row, 'media_type'))->toBe('youtube') + ->and(data_get($row, 'media_embed_url'))->toBe('https://www.youtube.com/embed/dQw4w9WgXcQ') + ->and(data_get($row, 'gambar_path'))->toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg') + ->and(data_get($row, 'media'))->toContain('data-media-type="youtube"'); +}); + test('update a galeri without changing image preserves existing image', function () { Storage::fake('public'); $album = Album::factory()->create(); diff --git a/tests/Feature/PublikasiGaleriPublikTest.php b/tests/Feature/PublikasiGaleriPublikTest.php new file mode 100644 index 0000000000..46e94bd7c5 --- /dev/null +++ b/tests/Feature/PublikasiGaleriPublikTest.php @@ -0,0 +1,65 @@ + 'sinkronisasi_database_gabungan'], + ['value' => '0'] + ); + + Cache::flush(); + + $this->album = Album::factory()->create([ + 'judul' => 'Album Publik', + 'slug' => 'album-publik', + ]); +}); + +it('renders the public galeri list page', function () { + $response = $this->get(route('publik.publikasi.galeri', 'album-publik')); + + $response->assertStatus(200); + $response->assertSee('galeri-container', false); +}); + +it('renders the public galeri detail page', function () { + $response = $this->get(route('publik.publikasi.galeri.detail', 'album-publik')); + + $response->assertStatus(200); + $response->assertSee('galeri-images-container', false); +}); + +it('builds the storage base url without a double slash', function () { + $response = $this->get(route('publik.publikasi.galeri.detail', 'album-publik')); + + // asset() membuang garis miring di akhir path, sehingga pemisah harus + // ditambahkan eksplisit. Tanpa itu nama berkas akan menempel ke folder. + $response->assertSee(json_encode(asset('storage/publikasi/galeri')), false) + ->assertSee("storageBaseUrl + '/' + item", false); + + // Dua garis miring sebelum folder akan membuat gambar gagal dimuat. + $response->assertDontSee('//storage/publikasi/galeri/'); +}); + +it('loads the shared media rendering helper on the galeri pages', function () { + $response = $this->get(route('publik.publikasi.galeri.detail', 'album-publik')); + + $response->assertSee('custom.js', false); +}); diff --git a/tests/Unit/Models/GaleriTest.php b/tests/Unit/Models/GaleriTest.php new file mode 100644 index 0000000000..86d3b13926 --- /dev/null +++ b/tests/Unit/Models/GaleriTest.php @@ -0,0 +1,134 @@ +album = Album::factory()->create(); +}); + +it('exposes image media for google drive photo links', function () { + $galeri = Galeri::create([ + 'album_id' => $this->album->id, + 'judul' => 'Foto Google Drive', + 'jenis' => 'url', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view?usp=sharing', + 'media_type' => 'image', + 'status' => true, + ]); + + expect($galeri->media_type)->toBe('image') + ->and($galeri->media_url)->toBe('https://drive.google.com/uc?export=view&id=1AbCdEf123') + ->and($galeri->media_embed_url)->toBeNull() + ->and($galeri->gambar_path)->toBe('https://drive.google.com/uc?export=view&id=1AbCdEf123'); +}); + +it('exposes video media for google drive video links', function () { + $galeri = Galeri::create([ + 'album_id' => $this->album->id, + 'judul' => 'Video Google Drive', + 'jenis' => 'url', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view', + 'media_type' => 'video', + 'status' => true, + ]); + + expect($galeri->media_type)->toBe('video') + ->and($galeri->media_embed_url)->toBe('https://drive.google.com/file/d/1AbCdEf123/preview') + ->and($galeri->media_url)->toContain('id=1AbCdEf123'); +}); + +it('exposes embed media for youtube links', function () { + $galeri = Galeri::create([ + 'album_id' => $this->album->id, + 'judul' => 'Video YouTube', + 'jenis' => 'url', + 'link' => 'https://youtu.be/dQw4w9WgXcQ', + 'status' => true, + ]); + + expect($galeri->media_type)->toBe('youtube') + ->and($galeri->media_embed_url)->toBe('https://www.youtube.com/embed/dQw4w9WgXcQ') + ->and($galeri->gambar_path)->toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg'); +}); + +it('exposes media url for direct video files', function () { + $galeri = Galeri::create([ + 'album_id' => $this->album->id, + 'judul' => 'Video MP4', + 'jenis' => 'url', + 'link' => 'https://sitim.go.id/video/kegiatan.mp4', + 'status' => true, + ]); + + expect($galeri->media_type)->toBe('video') + ->and($galeri->media_url)->toBe('https://sitim.go.id/video/kegiatan.mp4') + ->and($galeri->media_embed_url)->toBeNull(); +}); + +it('falls back to a local file thumbnail for uploaded media', function () { + Storage::disk('public')->put('publikasi/galeri/unggah.jpg', 'fake content'); + + $galeri = Galeri::create([ + 'album_id' => $this->album->id, + 'judul' => 'Foto Unggah', + 'jenis' => 'file', + 'gambar' => ['unggah.jpg'], + 'status' => true, + ]); + + expect($galeri->media_type)->toBe('image') + ->and($galeri->media_url)->toContain('publikasi/galeri/unggah.jpg') + ->and($galeri->media_embed_url)->toBeNull(); +}); + +it('returns a no image placeholder when nothing can be displayed', function () { + $galeri = new Galeri(); + + expect($galeri->media_type)->toBe('unknown') + ->and($galeri->media_url)->toBeNull() + ->and($galeri->media_embed_url)->toBeNull() + ->and($galeri->gambar_path)->toContain('no-image.png'); +}); + +it('keeps the original link intact for the detail page', function () { + $galeri = Galeri::create([ + 'album_id' => $this->album->id, + 'judul' => 'Link Asli', + 'jenis' => 'url', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view?usp=sharing', + 'media_type' => 'video', + 'status' => true, + ]); + + expect($galeri->link)->toBe('https://drive.google.com/file/d/1AbCdEf123/view?usp=sharing'); +}); + +it('serializes media fields for the api', function () { + $galeri = Galeri::create([ + 'album_id' => $this->album->id, + 'judul' => 'Serialisasi', + 'jenis' => 'url', + 'link' => 'https://drive.google.com/file/d/1AbCdEf123/view', + 'media_type' => 'video', + 'status' => true, + ]); + + $data = $galeri->toArray(); + + expect($data)->toHaveKeys(['media_type', 'media_url', 'media_embed_url', 'gambar_path']) + ->and($data['media_type'])->toBe('video') + ->and($data['media_embed_url'])->toBe('https://drive.google.com/file/d/1AbCdEf123/preview'); +}); diff --git a/tests/Unit/Services/MediaLinkServiceTest.php b/tests/Unit/Services/MediaLinkServiceTest.php new file mode 100644 index 0000000000..02348896bd --- /dev/null +++ b/tests/Unit/Services/MediaLinkServiceTest.php @@ -0,0 +1,248 @@ +service = new MediaLinkService(); +}); + +describe('youtube', function () { + it('resolves a regular watch url', function () { + $media = $this->service->resolve('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + expect($media->type)->toBe('youtube') + ->and($media->id)->toBe('dQw4w9WgXcQ') + ->and($media->embedUrl)->toBe('https://www.youtube.com/embed/dQw4w9WgXcQ') + ->and($media->thumbnail)->toBe('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg'); + }); + + it('resolves short, embed, shorts and live urls', function (string $url, string $id) { + expect($this->service->resolve($url)->type)->toBe('youtube') + ->and($this->service->resolve($url)->id)->toBe($id); + })->with([ + ['https://youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://www.youtube.com/embed/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://www.youtube.com/shorts/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://www.youtube.com/live/dQw4w9WgXcQ', 'dQw4w9WgXcQ'], + ['https://m.youtube.com/watch?v=dQw4w9WgXcQ&t=30s', 'dQw4w9WgXcQ'], + ]); + + it('ignores a non youtube url', function () { + expect($this->service->resolve('https://opendesa.id')->type)->not->toBe('youtube'); + }); +}); + +describe('google drive', function () { + it('resolves the file view url to a direct image url', function () { + $media = $this->service->resolve('https://drive.google.com/file/d/1AbCdEf123/view?usp=sharing'); + + expect($media->type)->toBe('image') + ->and($media->id)->toBe('1AbCdEf123') + ->and($media->url)->toBe('https://drive.google.com/uc?export=view&id=1AbCdEf123') + ->and($media->embedUrl)->toBeNull(); + }); + + it('resolves the uc export url', function () { + $media = $this->service->resolve('https://drive.google.com/uc?export=view&id=1AbCdEf123'); + + expect($media->id)->toBe('1AbCdEf123') + ->and($media->url)->toBe('https://drive.google.com/uc?export=view&id=1AbCdEf123'); + }); + + it('resolves the open id url', function () { + $media = $this->service->resolve('https://drive.google.com/open?id=1AbCdEf123'); + + expect($media->id)->toBe('1AbCdEf123') + ->and($media->url)->toBe('https://drive.google.com/uc?export=view&id=1AbCdEf123'); + }); + + it('resolves a drive video to a playable preview embed', function () { + $media = $this->service->resolve('https://drive.google.com/file/d/1AbCdEf123/view', 'video'); + + expect($media->type)->toBe('video') + ->and($media->embedUrl)->toBe('https://drive.google.com/file/d/1AbCdEf123/preview') + ->and($media->url)->toBe('https://drive.google.com/uc?export=download&id=1AbCdEf123'); + }); + + it('resolves the docs.google.com viewer url', function () { + expect($this->service->resolve('https://docs.google.com/file/d/1AbCdEf123/view')->id)->toBe('1AbCdEf123'); + }); + + it('marks a drive folder as unknown', function () { + $media = $this->service->resolve('https://drive.google.com/drive/folders/1AbCdEf123'); + + expect($media->type)->toBe('unknown') + ->and($media->id)->toBeNull(); + }); +}); + +describe('direct files', function () { + it('detects image urls by extension', function (string $url) { + $media = $this->service->resolve($url); + + expect($media->type)->toBe('image') + ->and($media->url)->toBe($url) + ->and($media->embedUrl)->toBeNull(); + })->with([ + ['https://contoh.sitim.go.id/foto/kegiatan.jpg'], + ['https://contoh.sitim.go.id/foto/kegiatan.JPEG'], + ['https://contoh.sitim.go.id/foto/kegiatan.png'], + ['https://contoh.sitim.go.id/foto/kegiatan.webp'], + ['https://contoh.sitim.go.id/foto/kegiatan.gif'], + ]); + + it('detects video urls by extension', function (string $url) { + $media = $this->service->resolve($url); + + expect($media->type)->toBe('video') + ->and($media->url)->toBe($url); + })->with([ + ['https://contoh.sitim.go.id/video/kegiatan.mp4'], + ['https://contoh.sitim.go.id/video/kegiatan.webm'], + ['https://contoh.sitim.go.id/video/kegiatan.ogv'], + ['https://contoh.sitim.go.id/video/kegiatan.mov'], + ]); + + it('resolves a vimeo url to an embed', function () { + $media = $this->service->resolve('https://vimeo.com/123456789'); + + expect($media->type)->toBe('video') + ->and($media->id)->toBe('123456789') + ->and($media->embedUrl)->toBe('https://player.vimeo.com/video/123456789'); + }); +}); + +describe('fallback', function () { + it('returns unknown for a plain url without media hints', function () { + $media = $this->service->resolve('https://opendesa.id'); + + expect($media->type)->toBe('unknown') + ->and($media->url)->toBe('https://opendesa.id') + ->and($media->embedUrl)->toBeNull() + ->and($media->thumbnail)->toBeNull(); + }); + + it('returns unknown for empty or invalid values', function (?string $url) { + $media = $this->service->resolve($url); + + expect($media->type)->toBe('unknown') + ->and($media->url)->toBeNull(); + })->with([null, '', ' ', 'bukan-url']); + + it('respects the manual type hint for unknown urls', function () { + expect($this->service->resolve('https://opendesa.id', 'image')->type)->toBe('image') + ->and($this->service->resolve('https://opendesa.id', 'video')->type)->toBe('video'); + }); + + it('never trusts a type hint that contradicts the provider', function () { + expect($this->service->resolve('https://youtu.be/dQw4w9WgXcQ', 'image')->type)->toBe('youtube'); + }); +}); + +describe('probing', function () { + it('returns the media type from the content-type header', function (string $contentType, string $expected) { + Http::fake(['*' => Http::response('', 200, ['Content-Type' => $contentType])]); + + expect($this->service->probe('https://contoh.sitim.go.id/media/berkas'))->toBe($expected); + })->with([ + ['image/jpeg', 'image'], + ['image/png', 'image'], + ['video/mp4', 'video'], + ['application/octet-stream', 'unknown'], + ]); + + it('falls back to the filename extension when content type is generic', function () { + Http::fake(['*' => Http::response('', 200, [ + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="dokumentasi.mp4"', + ])]); + + expect($this->service->probe('https://contoh.sitim.go.id/media/berkas'))->toBe('video'); + }); + + it('resolves the drive download url before probing', function () { + Http::fake([ + 'drive.google.com/*' => Http::response('', 200, ['Content-Type' => 'video/mp4']), + '*' => Http::response('', 500), + ]); + + expect($this->service->probe('https://drive.google.com/file/d/1AbCdEf123/view'))->toBe('video'); + + Http::assertSent(function (Request $request) { + return str_contains($request->url(), 'uc?export=download&id=1AbCdEf123'); + }); + }); + + it('returns null when the request fails', function () { + Http::fake(['*' => Http::response('', 500)]); + + expect($this->service->probe('https://contoh.sitim.go.id/media/berkas'))->toBeNull(); + }); + + it('does not probe empty urls', function () { + Http::preventStrayRequests(); + + expect($this->service->probe(null))->toBeNull() + ->and($this->service->probe('bukan-url'))->toBeNull(); + }); +}); + +describe('detectType', function () { + it('uses the resolved type without touching the network', function () { + Http::preventStrayRequests(); + + expect($this->service->detectType('https://youtu.be/dQw4w9WgXcQ'))->toBe('youtube') + ->and($this->service->detectType('https://contoh.sitim.go.id/foto.jpg'))->toBe('image') + ->and($this->service->detectType('https://contoh.sitim.go.id/video.mp4'))->toBe('video'); + }); + + it('probes only when the url is inconclusive', function () { + Http::fake(['*' => Http::response('', 200, ['Content-Type' => 'video/mp4'])]); + + expect($this->service->detectType('https://contoh.sitim.go.id/media/berkas'))->toBe('video'); + + Http::assertSentCount(1); + }); + + it('probes google drive links because they carry no file extension', function () { + Http::fake([ + 'drive.google.com/*' => Http::response('', 200, ['Content-Type' => 'video/mp4']), + '*' => Http::response('', 500), + ]); + + expect($this->service->detectType('https://drive.google.com/file/d/1AbCdEf123/view'))->toBe('video'); + + Http::assertSentCount(1); + }); + + it('falls back to the default drive type when probing fails', function () { + Http::fake(['*' => Http::response('', 500)]); + + expect($this->service->detectType('https://drive.google.com/file/d/1AbCdEf123/view'))->toBe('image'); + }); + + it('never probes when the user picked the type manually', function () { + Http::preventStrayRequests(); + + expect($this->service->detectType('https://contoh.sitim.go.id/media/berkas', 'image'))->toBe('image') + ->and($this->service->detectType('https://drive.google.com/file/d/1AbCdEf123/view', 'video'))->toBe('video'); + }); + + it('returns unknown for a missing link', function () { + Http::preventStrayRequests(); + + expect($this->service->detectType(null))->toBe('unknown'); + }); +}); diff --git a/tests/Unit/Support/CspPolicyTest.php b/tests/Unit/Support/CspPolicyTest.php new file mode 100644 index 0000000000..4bfc07fef9 --- /dev/null +++ b/tests/Unit/Support/CspPolicyTest.php @@ -0,0 +1,53 @@ +toHeader(); + + expect($policy)->toContain('*.youtube.com') + ->and($policy)->toContain('*.vimeo.com') + ->and($policy)->toContain('https://drive.google.com'); +}); + +it('allows external media sources for gallery videos', function () { + $directive = (new CspPolicy())->directive('media-src'); + + expect($directive)->toContain("'self'") + ->and($directive)->toContain('https:') + ->and($directive)->toContain('blob:'); +}); + +it('allows external images for gallery thumbnails', function () { + $directive = (new CspPolicy())->directive('img-src'); + + expect($directive)->toContain("'self'") + ->and($directive)->toContain('data:') + ->and($directive)->toContain('blob:'); +}); + +it('includes the database gabungan api in connect-src', function () { + config(['setting.api_server_database_gabungan' => 'https://api.gabungan.test']); + + expect((new CspPolicy())->toHeader())->toContain('connect-src') + ->toContain('https://api.gabungan.test'); +}); + +it('keeps a restrictive baseline for scripts and objects', function () { + $policy = new CspPolicy(); + + expect($policy->directive('default-src'))->toBe("'self'") + ->and($policy->directive('object-src'))->toBe("'self' blob:") + ->and($policy->directive('frame-ancestors'))->toBe("'self'") + ->and($policy->directive('base-uri'))->toBe("'self'"); +}); diff --git a/themes/opendk/default/resources/views/pages/publikasi/galeri.blade.php b/themes/opendk/default/resources/views/pages/publikasi/galeri.blade.php index a9f172bd6a..9a18d78b05 100644 --- a/themes/opendk/default/resources/views/pages/publikasi/galeri.blade.php +++ b/themes/opendk/default/resources/views/pages/publikasi/galeri.blade.php @@ -20,35 +20,25 @@ function renderGaleri(items) { return items.map(function(item) { var galeri = item.attributes; - var galeriImage = galeri.gambar_path || '{{ asset("/img/no-image.png") }}'; - - if (galeri.jenis === 'file') { - return '
' + - '
' + - '
' + - '' + (galeri.slug || '') + '' + - '
' + - '
' + - '
' + (galeri.judul || '') + '
' + - '

 ' + formatDate(galeri.created_at) + ' |  Administrator

' + - 'Selengkapnya' + - '
' + - '
' + - '
'; - } else { - return '
' + - '
' + - '
' + - '' + (galeri.slug || '') + '' + - '
' + - '
' + - '
' + (galeri.judul || '') + '
' + - '

 ' + formatDate(galeri.created_at) + ' |  Administrator

' + - 'Selengkapnya' + - '
' + + var media = renderGaleriMedia(galeri, { + imageStyle: 'width:100%;height:auto;max-height:220px;object-fit:contain;background:#f5f5f5;' + }); + var detailUrl = '{{ url("/publikasi/galeri/detail") }}/' + (galeri.slug || ''); + var targetUrl = (galeri.jenis === 'file') ? detailUrl : (galeri.link || detailUrl); + var badge = isGaleriPlayable(galeri) + ? ' ' + (galeri.media_type === 'youtube' ? 'YouTube' : 'Video') + '' + : ''; + + return '
' + + '
' + + '
' + badge + media + '
' + + '
' + + '
' + (galeri.judul || '') + '
' + + '

 ' + formatDate(galeri.created_at) + ' |  Administrator

' + + 'Selengkapnya' + '
' + - '
'; - } + '
' + + '
'; }).join(''); } diff --git a/themes/opendk/default/resources/views/pages/publikasi/galeri_detail.blade.php b/themes/opendk/default/resources/views/pages/publikasi/galeri_detail.blade.php index ca87152d7f..78c7cd7580 100644 --- a/themes/opendk/default/resources/views/pages/publikasi/galeri_detail.blade.php +++ b/themes/opendk/default/resources/views/pages/publikasi/galeri_detail.blade.php @@ -21,6 +21,10 @@ object-fit: cover; /* Menjaga proporsi gambar dengan crop jika perlu */ } + + .media-container { + margin-bottom: 15px; + } @endpush @@ -50,6 +54,10 @@ @push('scripts') + }, + error: function(xhr, status, error) { + console.error('Error loading galeri:', error); + var $container = $('#galeri-container .post.clearfix'); + $container.html('

Gagal memuat galeri. Silakan coba lagi nanti.

'); + } + }); + } + + // Load galeri on page load + loadGaleri(); + }); + @endpush diff --git a/themes/opendk/default/resources/views/pages/publikasi/galeri_detail.blade.php b/themes/opendk/default/resources/views/pages/publikasi/galeri_detail.blade.php index 78c7cd7580..2ee0dde43f 100644 --- a/themes/opendk/default/resources/views/pages/publikasi/galeri_detail.blade.php +++ b/themes/opendk/default/resources/views/pages/publikasi/galeri_detail.blade.php @@ -2,145 +2,145 @@ @section('title', 'Galeri') @push('css') - + @endpush @section('content') -
-
-
-
-

Galeri : - Loading... -

+
+
+
+
+

Galeri : + Loading... +

+
-
-
-
- -
- -

Loading galeri...

+
+
+ +
+ +

Loading galeri...

+
-
@endsection @push('scripts') - -@include('forms.image-modal') -@endpush \ No newline at end of file + }); + } + + // Load galeri detail on page load + loadGaleriDetail(); + + // Initialize image modal functionality + function initializeImageModal() { + // If there's a modal functionality, reinitialize it here + // This would depend on what's in 'forms.image-modal' + $('#myImg').on('click', function() { + var modal = document.getElementById("myModal"); + var modalImg = document.getElementById("img01"); + var captionText = document.getElementById("caption"); + + if (modal && modalImg) { + modal.style.display = "block"; + modalImg.src = this.src; + captionText.innerHTML = this.alt; + } + }); + } + }); + + @include('forms.image-modal') +@endpush From d0a49d796cba2afbf97dfbbd74af8dd8e7b40c94 Mon Sep 17 00:00:00 2001 From: Habibie Mahbub <56639378+habibie11@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:39:33 +0700 Subject: [PATCH 49/52] Fix: Menu Potensi pada Halaman Publik Tidak Berfungsi (#1768) Co-authored-by: Abah Roland --- .../Controllers/FrontEnd/PageController.php | 18 ++++- .../Controllers/Setting/NavMenuController.php | 14 +++- catatan_rilis.md | 1 + ...2026_09_30_080000_fix_potensi_nav_menu.php | 40 ++++++++++ database/seeders/DasNavigationTableSeeder.php | 4 +- .../views/setting/nav_menu/field.blade.php | 4 + .../views/setting/nav_menu/index.blade.php | 2 + .../views/setting/navigation/form.blade.php | 7 +- routes/web.php | 2 +- tests/Feature/Frontend/PotensiPublicTest.php | 73 +++++++++++++++++++ .../views/pages/potensi/index.blade.php | 34 +++++++-- .../views/pages/potensi/show.blade.php | 3 + 12 files changed, 183 insertions(+), 19 deletions(-) create mode 100644 database/migrations/2026_09_30_080000_fix_potensi_nav_menu.php create mode 100644 tests/Feature/Frontend/PotensiPublicTest.php diff --git a/app/Http/Controllers/FrontEnd/PageController.php b/app/Http/Controllers/FrontEnd/PageController.php index b665d330a2..1293e312ed 100644 --- a/app/Http/Controllers/FrontEnd/PageController.php +++ b/app/Http/Controllers/FrontEnd/PageController.php @@ -42,6 +42,7 @@ use App\Models\Event; use App\Models\Kategori; use App\Models\Survei; +use App\Models\TipePotensi; use App\Services\DesaService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -140,12 +141,23 @@ public function filterFeeds(Request $request): JsonResponse return response()->json(compact('html')); } - public function PotensiByKategory(string $slug): View + public function PotensiIndex(): View { $page_title = 'Potensi'; - $page_description = 'Potensi-Potensi'; + $page_description = 'Potensi-Potensi Kecamatan'; + $kategori_potensi = TipePotensi::all(); + + return view('pages.potensi.index', compact(['page_title', 'page_description', 'kategori_potensi'])); + } + + public function PotensiByKategory(string $slug): View + { + $kategori = TipePotensi::where('slug', $slug)->first(); + $page_title = 'Potensi' . ($kategori ? ' - ' . $kategori->nama_kategori : ''); + $page_description = 'Potensi-Potensi Kecamatan' . ($kategori ? ' Kategori ' . $kategori->nama_kategori : ''); + $kategori_potensi = TipePotensi::all(); - return view('pages.potensi.index', compact(['page_title', 'page_description', 'slug'])); + return view('pages.potensi.index', compact(['page_title', 'page_description', 'slug', 'kategori', 'kategori_potensi'])); } public function PotensiShow(string $kategori, string $id): View diff --git a/app/Http/Controllers/Setting/NavMenuController.php b/app/Http/Controllers/Setting/NavMenuController.php index 5855dbbe5b..98eaca5c10 100644 --- a/app/Http/Controllers/Setting/NavMenuController.php +++ b/app/Http/Controllers/Setting/NavMenuController.php @@ -33,14 +33,14 @@ use App\Http\Controllers\Controller; use App\Models\Artikel; +use App\Models\JenisDokumen; use App\Models\Kategori; use App\Models\NavMenu; -use App\Models\JenisDokumen; +use App\Models\TipePotensi; use Illuminate\Http\Request; class NavMenuController extends Controller { - public function index() { $page_title = 'Pengaturan Menu'; @@ -65,12 +65,17 @@ public function index() ), 'Kategori' => Kategori::cursor()->pluck('nama', 'link')->toArray(), 'Dokumen' => JenisDokumen::cursor()->pluck('nama', 'link')->toArray(), + 'Potensi' => array_merge( + ['/potensi' => 'Semua Potensi'], + TipePotensi::cursor()->mapWithKeys(function ($item) { + return ['/potensi/' . $item->slug => $item->nama_kategori]; + })->toArray() + ), ]; return view('setting.nav_menu.index', compact('page_title', 'page_description', 'nav_menus', 'sourceItem')); } - public function store(Request $request) { NavMenu::whereNotNull('id')->delete(); @@ -90,11 +95,12 @@ private function loopTree(array $elements, $parentId = null) { $sequence = 1; foreach ($elements as $element) { + $type = strtolower($element['type'] ?? ''); $input = [ 'name' => $element['text'], 'url' => $element['href'], 'target' => $element['target'], - 'type' => $element['type'], + 'type' => in_array($type, ['link', 'halaman', 'kategori', 'modul', 'dokumen']) ? $type : 'modul', 'is_show' => isset($element['is_show']) ? $element['is_show'] : 1, 'order' => $sequence, 'parent_id' => $parentId, diff --git a/catatan_rilis.md b/catatan_rilis.md index 95607af530..73ff564175 100644 --- a/catatan_rilis.md +++ b/catatan_rilis.md @@ -23,6 +23,7 @@ Terimakasih [isi disini] yang telah berkontribusi langsung mengembangkan aplikas 12. [#1737](https://github.com/OpenSID/OpenDK/issues/1737) fungsi kirim pesan error 500. 13. [#1761](https://github.com/OpenSID/OpenDK/issues/1761)Perbaikan validasi valid_file menolak PDF sah karena string generik "function". 14. [#1764](https://github.com/OpenSID/OpenDK/issues/1764) Gagal Menampilkan data foto/video dari link pada web dan admin. +15. [#1767](https://github.com/OpenSID/OpenDK/issues/1767)Perbaikan menu potensi pada halaman publik tidak berfungsi. #### TEKNIS diff --git a/database/migrations/2026_09_30_080000_fix_potensi_nav_menu.php b/database/migrations/2026_09_30_080000_fix_potensi_nav_menu.php new file mode 100644 index 0000000000..271da7b608 --- /dev/null +++ b/database/migrations/2026_09_30_080000_fix_potensi_nav_menu.php @@ -0,0 +1,40 @@ +where('url', '#') + ->update([ + 'url' => '/potensi', + 'target' => '_self', + 'type' => 'modul', + ]); + + try { + app(CacheService::class)->removeCachePrefix(config('theme-api.website.cache_prefix', 'website:api')); + } catch (\Throwable $e) { + // Abaikan jika cache service belum siap + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Tidak perlu direverse karena perbaikan data + } +}; diff --git a/database/seeders/DasNavigationTableSeeder.php b/database/seeders/DasNavigationTableSeeder.php index faffe95350..e31e22e5d4 100644 --- a/database/seeders/DasNavigationTableSeeder.php +++ b/database/seeders/DasNavigationTableSeeder.php @@ -77,8 +77,8 @@ public function run() [ 'name' => 'Potensi', 'slug' => Str::slug('Potensi'), - 'type' => MenuTipe::EKSTERNAL, - 'url' => '#', + 'type' => MenuTipe::POTENSI, + 'url' => 'potensi', 'order' => 4, 'status' => 1, ], diff --git a/resources/views/setting/nav_menu/field.blade.php b/resources/views/setting/nav_menu/field.blade.php index 11d434cbee..d2d4c086d1 100644 --- a/resources/views/setting/nav_menu/field.blade.php +++ b/resources/views/setting/nav_menu/field.blade.php @@ -24,6 +24,9 @@ +
@@ -63,6 +66,7 @@ case 'Kategori': case 'Halaman': case 'Dokumen': + case 'Potensi': let select = document.querySelector('select[name=sourcelist]'); select.style.display = 'block'; // Tampilkan select sourcelist diff --git a/resources/views/setting/nav_menu/index.blade.php b/resources/views/setting/nav_menu/index.blade.php index e5e081ce31..7cdd994eaf 100644 --- a/resources/views/setting/nav_menu/index.blade.php +++ b/resources/views/setting/nav_menu/index.blade.php @@ -157,6 +157,7 @@ let sourceKategori = $('#sourceKategori'); let sourceModul = $('#sourceModul'); let sourceDokumen = $('#sourceDokumen'); + let sourcePotensi = $('#sourcePotensi'); let source = ''; if (sourceLink.is(':checked')) source = sourceLink.val(); @@ -164,6 +165,7 @@ else if (sourceKategori.is(':checked')) source = sourceKategori.val(); else if (sourceModul.is(':checked')) source = sourceModul.val(); else if (sourceDokumen.is(':checked')) source = sourceDokumen.val(); + else if (sourcePotensi.is(':checked')) source = 'modul'; editor.add(source); }); diff --git a/resources/views/setting/navigation/form.blade.php b/resources/views/setting/navigation/form.blade.php index ee4e4548e6..e7e93a43fc 100644 --- a/resources/views/setting/navigation/form.blade.php +++ b/resources/views/setting/navigation/form.blade.php @@ -27,18 +27,19 @@ @endforeach + @foreach (\App\Enums\MenuTipe::getPotensi() as $key => $value) - + @endforeach @foreach (\App\Enums\MenuTipe::getUnduhan() as $key => $value) - + @endforeach @foreach (\App\Enums\MenuTipe::getPublikasi() as $key => $value) - + @endforeach diff --git a/routes/web.php b/routes/web.php index 1f0e59d33d..fcde690df4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -233,7 +233,7 @@ }); Route::group(['prefix' => 'potensi'], function () { - Route::permanentRedirect('/', '/'); + Route::get('/', 'PageController@PotensiIndex')->name('potensi.index'); Route::get('{slug}', 'PageController@PotensiByKategory')->name('potensi.kategori'); Route::get('{kategori}/{id}', 'PageController@PotensiShow')->name('potensi.kategori.show'); }); diff --git a/tests/Feature/Frontend/PotensiPublicTest.php b/tests/Feature/Frontend/PotensiPublicTest.php new file mode 100644 index 0000000000..9139b9c73f --- /dev/null +++ b/tests/Feature/Frontend/PotensiPublicTest.php @@ -0,0 +1,73 @@ +tipePotensi = TipePotensi::firstOrCreate( + ['slug' => 'pariwisata'], + ['nama_kategori' => 'Pariwisata'] + ); + + $this->potensi = Potensi::firstOrCreate( + ['nama_potensi' => 'Air Terjun Indah'], + [ + 'kategori_id' => $this->tipePotensi->id, + 'deskripsi' => 'Deskripsi wisata alam air terjun indah', + 'lokasi' => 'Kecamatan Contoh', + 'file_gambar' => 'storage/potensi_kecamatan/sample.jpg', + ] + ); +}); + +it('displays all potensi page at /potensi successfully', function () { + $response = $this->get('/potensi'); + + $response->assertOk(); + $response->assertViewIs('pages.potensi.index'); + $response->assertViewHas('page_title', 'Potensi'); + $response->assertViewHas('kategori_potensi'); +}); + +it('displays potensi by category page at /potensi/{slug} successfully', function () { + $response = $this->get('/potensi/' . $this->tipePotensi->slug); + + $response->assertOk(); + $response->assertViewIs('pages.potensi.index'); + $response->assertViewHas('slug', $this->tipePotensi->slug); + $response->assertViewHas('kategori_potensi'); +}); + +it('displays potensi detail page at /potensi/{kategori}/{id} successfully', function () { + $response = $this->get('/potensi/' . $this->tipePotensi->slug . '/' . $this->potensi->id); + + $response->assertOk(); + $response->assertViewIs('pages.potensi.show'); + $response->assertViewHas('id', (string) $this->potensi->id); +}); + +it('ensures nav_menus table contains /potensi for Potensi menu', function () { + $nav = NavMenu::where('name', 'Potensi')->first(); + + expect($nav)->not->toBeNull(); + expect($nav->url)->toBe('/potensi'); +}); + +it('returns /potensi in the website api navmenus data', function () { + $response = $this->getJson('/api/frontend/v1/website'); + + $response->assertOk(); + $data = $response->json('data'); + + $navmenusItem = collect($data)->firstWhere('id', 'navmenus'); + expect($navmenusItem)->not->toBeNull(); + + $menus = $navmenusItem['attributes'][0] ?? []; + $potensiMenu = collect($menus)->firstWhere('name', 'Potensi'); + + expect($potensiMenu)->not->toBeNull(); + expect($potensiMenu['url'])->toBe('/potensi'); +}); diff --git a/themes/opendk/default/resources/views/pages/potensi/index.blade.php b/themes/opendk/default/resources/views/pages/potensi/index.blade.php index 27cdc7c582..b1d8b89671 100644 --- a/themes/opendk/default/resources/views/pages/potensi/index.blade.php +++ b/themes/opendk/default/resources/views/pages/potensi/index.blade.php @@ -4,10 +4,18 @@
-

+

{{ $page_title ?? 'Potensi' }}

+ @if(isset($kategori_potensi) && $kategori_potensi->count() > 0) +
+ Semua + @foreach($kategori_potensi as $kat) + {{ $kat->nama_kategori }} + @endforeach +
+ @endif
@@ -26,6 +34,8 @@ @push('scripts')
AksiMedia Nama Album Aktif Dimuat pada