From 6ae00b79d92312e6dea6812e46a067476c0a2c79 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Mon, 12 Oct 2026 10:12:45 +0700 Subject: [PATCH] Import lokasi --- app/Exports/LokasiTemplateExport.php | 134 ++++ app/Http/Controllers/PlanController.php | 535 +++++++++++++++- .../Peta/BulkDestroyLokasiRequest.php | 37 ++ .../Peta/DownloadTemplateLokasiRequest.php | 35 ++ .../Requests/Peta/ImportLokasiRequest.php | 38 ++ app/Http/Requests/Peta/StoreLokasiRequest.php | 48 ++ .../Requests/Peta/UpdateLokasiRequest.php | 45 ++ app/Imports/LokasiImport.php | 222 +++++++ app/Rules/ValidKodeDesa.php | 32 + app/Services/BaseApiService.php | 69 ++ app/Services/PemetaanService.php | 110 ++-- app/Support/Coordinate.php | 20 + resources/views/peta/lokasi/filter.blade.php | 117 +++- resources/views/peta/lokasi/form.blade.php | 83 +++ resources/views/peta/lokasi/import.blade.php | 61 ++ .../peta/lokasi/import_summary.blade.php | 37 ++ resources/views/peta/lokasi/index.blade.php | 67 +- resources/views/peta/lokasi/js/app.blade.php | 373 +++++++++++ resources/views/peta/lokasi/js/data.blade.php | 91 --- .../views/peta/lokasi/js/filter.blade.php | 18 - resources/views/peta/lokasi/table.blade.php | 4 +- routes/web.php | 7 + tests/Browser/SmokeLokasiImportTest.php | 45 ++ tests/Browser/SmokeLokasiTest.php | 9 +- tests/Browser/fixtures/plan-lokasi.json | 2 + tests/Feature/Peta/LokasiImportTest.php | 595 ++++++++++++++++++ 26 files changed, 2598 insertions(+), 236 deletions(-) create mode 100644 app/Exports/LokasiTemplateExport.php create mode 100644 app/Http/Requests/Peta/BulkDestroyLokasiRequest.php create mode 100644 app/Http/Requests/Peta/DownloadTemplateLokasiRequest.php create mode 100644 app/Http/Requests/Peta/ImportLokasiRequest.php create mode 100644 app/Http/Requests/Peta/StoreLokasiRequest.php create mode 100644 app/Http/Requests/Peta/UpdateLokasiRequest.php create mode 100644 app/Imports/LokasiImport.php create mode 100644 app/Rules/ValidKodeDesa.php create mode 100644 app/Support/Coordinate.php create mode 100644 resources/views/peta/lokasi/form.blade.php create mode 100644 resources/views/peta/lokasi/import.blade.php create mode 100644 resources/views/peta/lokasi/import_summary.blade.php create mode 100644 resources/views/peta/lokasi/js/app.blade.php delete mode 100644 resources/views/peta/lokasi/js/data.blade.php delete mode 100644 resources/views/peta/lokasi/js/filter.blade.php create mode 100644 tests/Browser/SmokeLokasiImportTest.php create mode 100644 tests/Feature/Peta/LokasiImportTest.php diff --git a/app/Exports/LokasiTemplateExport.php b/app/Exports/LokasiTemplateExport.php new file mode 100644 index 000000000..c7d496dd8 --- /dev/null +++ b/app/Exports/LokasiTemplateExport.php @@ -0,0 +1,134 @@ + + */ + public const COLUMNS = [ + ['key' => 'desa', 'label' => 'Nama Desa', 'required' => true], + ['key' => 'nama', 'label' => 'Nama Lokasi', 'required' => true], + ['key' => 'lat', 'label' => 'Latitude', 'required' => true], + ['key' => 'lng', 'label' => 'Longitude', 'required' => true], + ['key' => 'keterangan', 'label' => 'Keterangan', 'required' => false], + ]; + + public const SHEET_DATA = 'Data Lokasi'; + + public const SHEET_DESA = 'Daftar Desa'; + + /** + * Jumlah baris konteks sebelum baris header. + */ + private const CONTEXT_ROWS = 5; + + /** + * @param array $desaList + */ + public function __construct( + private array $desaList, + private string $namaJenis, + private string $namaKategori, + ) {} + + public function write(string $filePath): void + { + $options = new Options; + $options->SHOULD_PRESERVE_EMPTY_ROWS = true; + + $writer = new Writer($options); + $writer->openToFile($filePath); + + $headerStyle = (new Style)->setFontBold()->setBackgroundColor(Color::YELLOW); + $infoLabelStyle = (new Style)->setFontBold(); + $noteStyle = new Style; + + $writer->getCurrentSheet()->setName(self::SHEET_DATA); + + // Baris konteks (Jenis/Kategori). Nama Desa TIDAK di sini (kolom per baris). + $writer->addRow(Row::fromValues(['Jenis Lokasi', $this->namaJenis], $infoLabelStyle)); + $writer->addRow(Row::fromValues(['Kategori Lokasi', $this->namaKategori])); + $writer->addRow(Row::fromValues([''])); + $writer->addRow(Row::fromValues([ + 'Catatan: Pilih Nama Desa pada tiap baris data (dropdown). Kode desa ditentukan otomatis oleh sistem saat import.', + ])); + $writer->addRow(Row::fromValues([''])); + + // Header kolom data. + $writer->addRow(Row::fromValues( + array_map(fn (array $column): string => $column['label'], self::COLUMNS) + )); + + // Sheet referensi desa. + $writer->addNewSheetAndMakeItCurrent(); + $writer->getCurrentSheet()->setName(self::SHEET_DESA); + $writer->addRow(Row::fromValues(['Kode Desa', 'Nama Desa'], (new Style)->setFontBold())); + + foreach ($this->desaList as $desa) { + $writer->addRow(Row::fromValues([ + (string) ($desa['kode_desa'] ?? ''), + (string) ($desa['nama_desa'] ?? ''), + ])); + } + + $writer->close(); + + $this->injectDesaDropdown($filePath); + } + + /** + * Tambahkan data validation (dropdown) Nama Desa pada kolom pertama data. + * OpenSpout belum mendukung data validation, sehingga XML xlsx diproses manual. + */ + protected function injectDesaDropdown(string $filePath): void + { + if ($this->desaList === []) { + return; + } + + $zip = new ZipArchive; + if ($zip->open($filePath) !== true) { + return; + } + + $sheetPath = 'xl/worksheets/sheet1.xml'; + $xml = $zip->getFromName($sheetPath); + + if ($xml === false) { + $zip->close(); + + return; + } + + $dataStartRow = self::CONTEXT_ROWS + 2; // baris setelah header + $lastRow = $dataStartRow + 999; + $lastDesaRow = count($this->desaList) + 1; + $formula = sprintf("'%s'!\$B\$2:\$B\$%d", self::SHEET_DESA, $lastDesaRow); + + $validation = '' + .htmlspecialchars($formula, ENT_XML1 | ENT_QUOTES, 'UTF-8') + .''; + + // Sisipkan setelah tanpa preg_replace (agar `$` pada formula + // tidak ditafsirkan sebagai backreference regex). + $pos = strpos($xml, ''); + + if ($pos !== false) { + $xml = substr_replace($xml, ''.$validation, $pos, strlen('')); + $zip->addFromString($sheetPath, $xml); + } + + $zip->close(); + } +} diff --git a/app/Http/Controllers/PlanController.php b/app/Http/Controllers/PlanController.php index f00b2c016..d79eedd72 100644 --- a/app/Http/Controllers/PlanController.php +++ b/app/Http/Controllers/PlanController.php @@ -3,52 +3,539 @@ namespace App\Http\Controllers; use App\Enums\AccessTypeEnum; +use App\Exports\LokasiTemplateExport; +use App\Http\Requests\Peta\BulkDestroyLokasiRequest; +use App\Http\Requests\Peta\DownloadTemplateLokasiRequest; +use App\Http\Requests\Peta\ImportLokasiRequest; +use App\Http\Requests\Peta\StoreLokasiRequest; +use App\Http\Requests\Peta\UpdateLokasiRequest; +use App\Imports\LokasiImport; +use App\Services\ConfigApiService; use App\Services\PemetaanService; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Session; +use Illuminate\View\View; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Throwable; class PlanController extends Controller { private int $tip = 3; - public function __construct(private PemetaanService $peta) + public function __construct( + private PemetaanService $peta, + private ConfigApiService $config, + ) {} + + public function index(int|string $parent = 0): View + { + $point = $this->peta->getAllPoint(); + + return view('peta.lokasi.index', [ + 'tip' => $this->tip, + 'parent' => $parent, + 'status' => [ + AccessTypeEnum::LOCK->value() => 'Aktif', + AccessTypeEnum::UNLOCK->value() => 'Tidak Aktif', + ], + 'point' => $point, + 'jenisKategori' => $this->jenisKategoriForJs($point), + 'desa' => $this->currentDesa(), + 'desaList' => $this->desaList(), + ]); + } + + /** + * Bentuk data Jenis + Kategori untuk konsumsi JavaScript. + * + * @param array> $point + * @return array}> + */ + private function jenisKategoriForJs(array $point): array + { + return collect($point)->map(fn (array $item): array => [ + 'id' => (string) ($item['id'] ?? ''), + 'nama' => (string) ($item['nama'] ?? ''), + 'children' => collect($item['children'] ?? [])->map(fn (array $child): array => [ + 'id' => (string) ($child['id'] ?? ''), + 'nama' => (string) ($child['nama'] ?? ''), + ])->values()->all(), + ])->values()->all(); + } + + public function ajax_lokasi_maps(string $parent, int $id): View + { + return view('peta.lokasi.maps', [ + 'lokasi' => $this->peta->getAllPlan(['filter[id]' => $id]), + 'parent' => $parent, + 'id' => $id, + ]); + } + + public function show_ajax_lokasi_maps(string $parent, int $id): View + { + return view('peta.lokasi.template_maps', [ + 'lokasi' => $this->peta->getAllPlan(['filter[id]' => $id]), + 'parent' => $parent, + 'id' => $id, + ]); + } + + public function detail(string $id): JsonResponse + { + $plan = $this->peta->planById($id); + + if ($plan === null) { + return response()->json(['success' => false, 'message' => 'Data lokasi tidak ditemukan.'], 404); + } + + return response()->json(['success' => true, 'data' => $plan]); + } + + public function import(ImportLokasiRequest $request): RedirectResponse + { + $master = $this->findJenisKategori($request->validated('jenis'), $request->validated('kategori')); + + if ($master === null) { + return $this->redirectBackWith('error', 'Kombinasi Jenis dan Kategori tidak valid.'); + } + + $import = new LokasiImport; + $result = $import->process($request->file('file')->getRealPath()); + + if (empty($result['meta']) && $result['valid'] === [] && $result['errors'] !== []) { + return $this->redirectBackWith('error', 'File tidak dapat diproses. '.$this->firstErrorMessage($result['errors'])); + } + + $summary = $this->processImport($result, $master); + + Session::flash('import_summary', $summary); + + if ($summary['success'] > 0) { + Session::flash('success', "Import selesai: {$summary['success']} berhasil, {$summary['failed']} gagal."); + } else { + Session::flash('error', "Import gagal: {$summary['failed']} baris ditolak."); + } + + return redirect()->route('plan'); + } + + public function downloadTemplate(DownloadTemplateLokasiRequest $request): BinaryFileResponse|RedirectResponse + { + $master = $this->findJenisKategori($request->validated('jenis'), $request->validated('kategori')); + + if ($master === null) { + return $this->redirectBackWith('error', 'Kombinasi Jenis dan Kategori tidak valid.'); + } + + $directory = storage_path('app/templates'); + if (! is_dir($directory)) { + mkdir($directory, 0775, true); + } + + $filePath = $directory.'/template_lokasi_'.date('YmdHis').'.xlsx'; + + (new LokasiTemplateExport( + $this->desaList()->values()->all(), + (string) $master['jenis']['nama'], + (string) $master['kategori']['nama'], + ))->write($filePath); + + return response()->download($filePath)->deleteFileAfterSend(true); + } + + public function store(StoreLokasiRequest $request): RedirectResponse + { + $desa = $this->resolveDesa((string) $request->validated('kode_desa')); + + if ($desa === null) { + return $this->redirectBackWith('error', 'Desa yang dipilih tidak valid.'); + } + + $master = $this->findJenisKategori($request->validated('jenis'), $request->validated('kategori')); + + if ($master === null) { + return $this->redirectBackWith('error', 'Kombinasi Jenis dan Kategori tidak valid.'); + } + + try { + $this->peta->planStore($this->buildPayload($request->validated(), $master, $desa)); + Session::flash('success', 'Data lokasi berhasil disimpan.'); + } catch (Throwable $e) { + report($e); + Session::flash('error', 'Terjadi kesalahan saat menyimpan data lokasi.'); + } + + return redirect()->route('plan'); + } + + public function update(UpdateLokasiRequest $request, string $id): RedirectResponse + { + $existing = $this->peta->planById($id); + + if ($existing === null) { + return $this->redirectBackWith('error', 'Data lokasi tidak ditemukan.'); + } + + $kodeDesa = (string) ($request->validated('kode_desa') ?? ($existing['kode_desa'] ?? session('desa.kode_desa') ?? '')); + + $desa = $this->resolveDesa($kodeDesa); + + if ($desa === null) { + return $this->redirectBackWith('error', 'Desa yang dipilih tidak valid.'); + } + + $master = $this->findJenisKategori( + $request->validated('jenis') ?? ($existing['jenis_id'] ?? $existing['jenis'] ?? null), + $request->validated('kategori') ?? ($existing['kategori_id'] ?? $existing['kategori'] ?? null), + ); + + if ($master === null) { + return $this->redirectBackWith('error', 'Kombinasi Jenis dan Kategori tidak valid.'); + } + + try { + $this->peta->planUpdate($this->buildPayload($request->validated(), $master, $desa), $id); + Session::flash('success', 'Data lokasi berhasil diperbarui.'); + } catch (Throwable $e) { + report($e); + Session::flash('error', 'Terjadi kesalahan saat memperbarui data lokasi.'); + } + + return redirect()->route('plan'); + } + + public function destroy(string $id): RedirectResponse + { + try { + $this->peta->planDelete($id); + Session::flash('success', 'Data lokasi berhasil dihapus.'); + } catch (Throwable $e) { + report($e); + Session::flash('error', 'Terjadi kesalahan saat menghapus data lokasi.'); + } + + return redirect()->route('plan'); + } + + public function bulkDestroy(BulkDestroyLokasiRequest $request): RedirectResponse + { + $ids = array_map('intval', (array) $request->validated('ids')); + + try { + $this->peta->planBulkDelete($ids); + Log::info('Plan bulk delete berhasil', ['ids' => $ids, 'user_id' => auth()->id()]); + Session::flash('success', count($ids).' data lokasi berhasil dihapus.'); + } catch (Throwable $e) { + report($e); + Log::warning('Plan bulk delete gagal', ['ids' => $ids, 'user_id' => auth()->id()]); + Session::flash('error', 'Terjadi kesalahan saat menghapus data lokasi.'); + } + + return redirect()->route('plan'); + } + + /** + * Validasi lokal per baris lalu kirim seluruh baris valid sebagai SATU payload batch. + * Desa ditentukan PER BARIS dari kolom `Nama Desa`. + * + * @param array{meta: array, rows: array>, errors: array>, valid: array>} $result + * @param array{jenis: array, kategori: array} $master + * @return array{total: int, success: int, failed: int, errors: array} + */ + private function processImport(array $result, array $master): array { + $errors = array_map($this->formatRowError(...), $result['errors']); + + $desaByName = $this->desaNameMap(); + $existingPerDesa = []; + $validRows = []; + + foreach ($result['valid'] as $row) { + $namaDesa = trim((string) ($row['desa'] ?? '')); + $key = mb_strtolower($namaDesa); + $desa = $namaDesa !== '' && array_key_exists($key, $desaByName) ? $desaByName[$key] : null; + + if ($desa === null) { + $errors[] = 'Baris '.$row['_row'].': Nama Desa "'.$namaDesa.'" tidak ditemukan atau ganda.'; + + continue; + } + + $kodeDesa = (string) $desa['kode_desa']; + + if (! array_key_exists($kodeDesa, $existingPerDesa)) { + $existingPerDesa[$kodeDesa] = $this->existingLokasiNames($kodeDesa, $master['jenis']['id'], $master['kategori']['id']); + } + + $namaKey = mb_strtolower(trim((string) ($row['nama'] ?? ''))); + + if (isset($existingPerDesa[$kodeDesa][$namaKey])) { + $errors[] = 'Baris '.$row['_row'].': data dengan nama "'.$row['nama'].'" sudah ada di desa '.$desa['nama_desa'].' (hanya data baru yang dapat diimport).'; + + continue; + } + + $existingPerDesa[$kodeDesa][$namaKey] = true; + $validRows[] = ['row' => $row, 'config_id' => (int) $desa['id']]; + } + + $summary = [ + 'total' => count($result['rows']), + 'success' => 0, + 'failed' => count($errors), + 'errors' => $errors, + ]; + + if ($validRows === []) { + return $summary; + } + + try { + $refPoint = (int) $master['kategori']['id']; + + $payload = [ + 'data' => array_map( + fn (array $item): array => $this->rowPayload($item['row'], $item['config_id'], $refPoint), + $validRows, + ), + ]; + + $apiResult = $this->peta->planImport($payload); + + $apiSummary = (array) ($apiResult['summary'] ?? []); + $apiFailed = (int) ($apiSummary['failed'] ?? 0); + $apiSuccess = (int) ($apiSummary['success'] ?? (count($validRows) - $apiFailed)); + + $summary['success'] = $apiSuccess; + $summary['failed'] += $apiFailed; + + foreach ((array) ($apiResult['errors'] ?? []) as $error) { + $summary['errors'][] = is_string($error) + ? $error + : 'Baris '.($error['row'] ?? '-').': '.($error['message'] ?? 'Data ditolak API.'); + } + } catch (Throwable $e) { + report($e); + $summary['failed'] += count($validRows); + $summary['errors'][] = 'Gagal mengirim data ke API database gabungan.'; + } + + return $summary; + } + + /** + * Payload item plan untuk API database gabungan (store/update). + * Mengikuti bentuk item plan: `config_id`, `nama`, `desk`, `lat`, `lng`, `ref_point`. + * + * @param array $data + * @param array{jenis: array, kategori: array} $master + * @param array{id: string, kode_desa: string, nama_desa: string} $desa + * @return array + */ + private function buildPayload(array $data, array $master, array $desa): array + { + return [ + 'config_id' => (int) $desa['id'], + 'nama' => trim((string) ($data['nama'] ?? '')), + 'desk' => trim((string) ($data['keterangan'] ?? '')), + 'lat' => (string) ($data['lat'] ?? ''), + 'lng' => (string) ($data['lng'] ?? ''), + 'ref_point' => (int) $master['kategori']['id'], + ]; } - public function index($parent = 0) + /** + * @param array $row + * @return array{config_id: int, nama: string, desk: string, lat: string, lng: string, ref_point: int} + */ + private function rowPayload(array $row, int $configId, int $refPoint): array { - $data = ['tip' => $this->tip, 'parent' => $parent]; - $data['status'] = [ - AccessTypeEnum::LOCK->value() => 'Aktif', - AccessTypeEnum::UNLOCK->value() => 'Tidak Aktif', + return [ + 'config_id' => $configId, + 'nama' => trim((string) ($row['nama'] ?? '')), + 'desk' => trim((string) ($row['keterangan'] ?? '')), + 'lat' => (string) ($row['lat'] ?? ''), + 'lng' => (string) ($row['lng'] ?? ''), + 'ref_point' => $refPoint, ]; - // $data['point'] = Point::root()->with(['children'])->where('sumber', 'OpenKab')->get(); + } + + /** + * Cari jenis (root point) dan kategori (child) dari master point. + * + * @return array{jenis: array, kategori: array}|null + */ + private function findJenisKategori(mixed $jenisId, mixed $kategoriId): ?array + { + if ($jenisId === null || $kategoriId === null) { + return null; + } + + foreach ($this->peta->getAllPoint() as $root) { + if ((string) ($root['id'] ?? '') !== (string) $jenisId) { + continue; + } - $data['point'] = $this->peta->getAllPoint(); + foreach ($root['children'] ?? [] as $child) { + if ((string) ($child['id'] ?? '') === (string) $kategoriId) { + return ['jenis' => $root, 'kategori' => $child]; + } + } + } - return view('peta.lokasi.index', $data); + return null; } - public function ajax_lokasi_maps($parent, int $id) + /** + * @return array + */ + private function existingLokasiNames(string $kodeDesa, mixed $jenisId, mixed $kategoriId): array { - // $data['lokasi'] = Lokasi::with('point')->findOrFail($id)->toArray(); + $names = []; - $data['lokasi'] = $this->peta->getAllPlan([ - 'filter[id]' => $id, - ]); + try { + $plans = $this->peta->getAllPlan([ + 'filter[kode_desa]' => $kodeDesa, + 'filter[jenis]' => $jenisId, + 'filter[kategori]' => $kategoriId, + 'page[size]' => 10000, + ]); + } catch (Throwable $e) { + report($e); + + return $names; + } - $data['parent'] = $parent; - $data['id'] = $id; + foreach ((array) $plans as $plan) { + $plan = (array) $plan; + if (isset($plan['nama'])) { + $names[mb_strtolower(trim((string) $plan['nama']))] = true; + } + } - return view('peta.lokasi.maps', $data); + return $names; } - public function show_ajax_lokasi_maps($parent, int $id) + /** + * @param array $error + */ + private function formatRowError(array $error): string { - $data['lokasi'] = $this->peta->getAllPlan([ - 'filter[id]' => $id, - ]); - $data['parent'] = $parent; - $data['id'] = $id; + $row = $error['row'] ?? 0; + + if (isset($error['messages']) && is_array($error['messages'])) { + return 'Baris '.$row.': '.implode(' ', $error['messages']); + } + + return 'Baris '.$row.': '.($error['message'] ?? 'Data tidak valid.'); + } + + /** + * @param array> $errors + */ + private function firstErrorMessage(array $errors): string + { + return $errors === [] ? '' : $this->formatRowError($errors[0]); + } + + /** + * @return array{kode_desa?: string, nama_desa?: string} + */ + private function currentDesa(): array + { + $desa = session('desa'); + + if (is_object($desa)) { + $desa = (array) $desa; + } + + if (is_array($desa) && ! empty($desa['kode_desa'])) { + return [ + 'kode_desa' => (string) $desa['kode_desa'], + 'nama_desa' => (string) ($desa['nama_desa'] ?? ''), + ]; + } + + $kodeDesa = (string) (session('kode_desa') ?? ''); + + return $kodeDesa !== '' ? ['kode_desa' => $kodeDesa] : []; + } + + /** + * @return array{id: string, kode_desa: string, nama_desa: string}|null + */ + private function resolveDesa(string $kodeDesa): ?array + { + if ($kodeDesa === '') { + return null; + } - return view('peta.lokasi.template_maps', $data); + $found = $this->desaList()->firstWhere('kode_desa', $kodeDesa); + + if ($found !== null) { + return [ + 'id' => (string) $found['id'], + 'kode_desa' => $found['kode_desa'], + 'nama_desa' => $found['nama_desa'], + ]; + } + + if ((string) session('desa.kode_desa') === $kodeDesa) { + return [ + 'id' => (string) (session('desa.id') ?? ''), + 'kode_desa' => $kodeDesa, + 'nama_desa' => (string) session('desa.nama_desa'), + ]; + } + + return null; + } + + /** + * Peta `nama_desa` (lowercase) ke data desa. Nilai `null` menandakan nama ganda (ambigu). + * + * @return array + */ + private function desaNameMap(): array + { + $map = []; + + foreach ($this->desaList() as $desa) { + $key = mb_strtolower(trim((string) $desa['nama_desa'])); + + if ($key === '') { + continue; + } + + $map[$key] = array_key_exists($key, $map) ? null : $desa; + } + + return $map; + } + + /** + * @return Collection + */ + private function desaList(): Collection + { + return collect($this->config->desaConfig()) + ->map(fn ($desa): array => [ + 'id' => $desa->id ?? '', + 'kode_desa' => (string) ($desa->kode_desa ?? ''), + 'nama_desa' => (string) ($desa->nama_desa ?? ''), + 'nama_kecamatan' => (string) ($desa->nama_kecamatan ?? ''), + ]) + ->filter(fn (array $desa): bool => $desa['kode_desa'] !== '') + ->sortBy('nama_desa') + ->values(); + } + + private function redirectBackWith(string $key, string $message): RedirectResponse + { + return redirect()->route('plan')->with($key, $message); } } diff --git a/app/Http/Requests/Peta/BulkDestroyLokasiRequest.php b/app/Http/Requests/Peta/BulkDestroyLokasiRequest.php new file mode 100644 index 000000000..293f494c9 --- /dev/null +++ b/app/Http/Requests/Peta/BulkDestroyLokasiRequest.php @@ -0,0 +1,37 @@ +> + */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['integer'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'ids.required' => 'Pilih minimal satu data lokasi.', + 'ids.array' => 'Format data lokasi terpilih tidak valid.', + 'ids.min' => 'Pilih minimal satu data lokasi.', + 'ids.*.integer' => 'Id lokasi tidak valid.', + ]; + } +} diff --git a/app/Http/Requests/Peta/DownloadTemplateLokasiRequest.php b/app/Http/Requests/Peta/DownloadTemplateLokasiRequest.php new file mode 100644 index 000000000..2c3f4486a --- /dev/null +++ b/app/Http/Requests/Peta/DownloadTemplateLokasiRequest.php @@ -0,0 +1,35 @@ +> + */ + public function rules(): array + { + return [ + 'jenis' => ['required'], + 'kategori' => ['required'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'jenis.required' => 'Jenis lokasi wajib dipilih.', + 'kategori.required' => 'Kategori lokasi wajib dipilih.', + ]; + } +} diff --git a/app/Http/Requests/Peta/ImportLokasiRequest.php b/app/Http/Requests/Peta/ImportLokasiRequest.php new file mode 100644 index 000000000..f21c6f42e --- /dev/null +++ b/app/Http/Requests/Peta/ImportLokasiRequest.php @@ -0,0 +1,38 @@ +> + */ + public function rules(): array + { + return [ + 'file' => ['required', 'file', 'mimes:xlsx,xls', 'max:10240'], + 'jenis' => ['required'], + 'kategori' => ['required'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'file.required' => 'File template wajib dipilih.', + 'file.mimes' => 'File harus berformat Excel (.xlsx / .xls).', + 'jenis.required' => 'Jenis lokasi wajib dipilih.', + 'kategori.required' => 'Kategori lokasi wajib dipilih.', + ]; + } +} diff --git a/app/Http/Requests/Peta/StoreLokasiRequest.php b/app/Http/Requests/Peta/StoreLokasiRequest.php new file mode 100644 index 000000000..d96d0b500 --- /dev/null +++ b/app/Http/Requests/Peta/StoreLokasiRequest.php @@ -0,0 +1,48 @@ +> + */ + public function rules(): array + { + return [ + 'nama' => ['required', 'string', 'max:50'], + 'jenis' => ['required'], + 'kategori' => ['required'], + 'kode_desa' => ['required', new ValidKodeDesa], + 'lat' => ['required', 'numeric', 'between:'.Coordinate::MIN_LAT.','.Coordinate::MAX_LAT], + 'lng' => ['required', 'numeric', 'between:'.Coordinate::MIN_LNG.','.Coordinate::MAX_LNG], + 'keterangan' => ['nullable', 'string', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'nama.required' => 'Nama lokasi wajib diisi.', + 'jenis.required' => 'Jenis lokasi wajib dipilih.', + 'kategori.required' => 'Kategori lokasi wajib dipilih.', + 'kode_desa.required' => 'Desa wajib dipilih.', + 'lat.required' => 'Latitude wajib diisi.', + 'lat.between' => 'Latitude harus berada di wilayah Indonesia ('.Coordinate::MIN_LAT.' s.d. '.Coordinate::MAX_LAT.').', + 'lng.required' => 'Longitude wajib diisi.', + 'lng.between' => 'Longitude harus berada di wilayah Indonesia ('.Coordinate::MIN_LNG.' s.d. '.Coordinate::MAX_LNG.').', + ]; + } +} diff --git a/app/Http/Requests/Peta/UpdateLokasiRequest.php b/app/Http/Requests/Peta/UpdateLokasiRequest.php new file mode 100644 index 000000000..6c2888e1c --- /dev/null +++ b/app/Http/Requests/Peta/UpdateLokasiRequest.php @@ -0,0 +1,45 @@ +> + */ + public function rules(): array + { + return [ + 'nama' => ['required', 'string', 'max:50'], + 'jenis' => ['nullable'], + 'kategori' => ['nullable'], + 'kode_desa' => ['nullable', new ValidKodeDesa], + 'lat' => ['required', 'numeric', 'between:'.Coordinate::MIN_LAT.','.Coordinate::MAX_LAT], + 'lng' => ['required', 'numeric', 'between:'.Coordinate::MIN_LNG.','.Coordinate::MAX_LNG], + 'keterangan' => ['nullable', 'string', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'nama.required' => 'Nama lokasi wajib diisi.', + 'lat.required' => 'Latitude wajib diisi.', + 'lat.between' => 'Latitude harus berada di wilayah Indonesia ('.Coordinate::MIN_LAT.' s.d. '.Coordinate::MAX_LAT.').', + 'lng.required' => 'Longitude wajib diisi.', + 'lng.between' => 'Longitude harus berada di wilayah Indonesia ('.Coordinate::MIN_LNG.' s.d. '.Coordinate::MAX_LNG.').', + ]; + } +} diff --git a/app/Imports/LokasiImport.php b/app/Imports/LokasiImport.php new file mode 100644 index 000000000..c32d2210f --- /dev/null +++ b/app/Imports/LokasiImport.php @@ -0,0 +1,222 @@ +, rows: array>, errors: array>, valid: array>} + */ + protected array $result = [ + 'meta' => [], + 'rows' => [], + 'errors' => [], + 'valid' => [], + ]; + + public function __construct() {} + + /** + * Baca dan validasi file template (multi-desa: `Nama Desa` per baris). + * + * @return array{meta: array, rows: array>, errors: array>, valid: array>} + */ + public function process(string $filePath): array + { + $this->result = [ + 'meta' => [], + 'rows' => [], + 'errors' => [], + 'valid' => [], + ]; + + if (! is_file($filePath)) { + $this->result['errors'][] = ['row' => 0, 'message' => 'File tidak ditemukan.']; + + return $this->result; + } + + try { + $sheetRows = $this->readSheet($filePath); + } catch (Throwable $e) { + report($e); + $this->result['errors'][] = ['row' => 0, 'message' => 'Format file tidak dapat dibaca. Pastikan menggunakan template yang benar (.xlsx).']; + + return $this->result; + } + + $this->parse($sheetRows); + + return $this->result; + } + + /** + * @return array> + */ + protected function readSheet(string $filePath): array + { + $options = new Options; + $options->SHOULD_PRESERVE_EMPTY_ROWS = true; + + $reader = new Reader($options); + $reader->open($filePath); + + $rows = []; + + foreach ($reader->getSheetIterator() as $sheet) { + foreach ($sheet->getRowIterator() as $rowNumber => $row) { + $values = []; + foreach ($row->getCells() as $cell) { + $value = $cell->getValue(); + if ($value instanceof \DateTimeInterface) { + $value = $value->format('Y-m-d'); + } + $values[] = is_scalar($value) || $value === null ? trim((string) $value) : ''; + } + $rows[(int) $rowNumber] = $values; + } + + break; // Hanya sheet pertama ("Data Lokasi"). + } + + $reader->close(); + + return $rows; + } + + /** + * @param array> $sheetRows + */ + protected function parse(array $sheetRows): void + { + $metaKeys = [ + 'jenis lokasi' => 'jenis', + 'kategori lokasi' => 'kategori', + ]; + + $labelToKey = []; + foreach (LokasiTemplateExport::COLUMNS as $column) { + $labelToKey[strtolower($column['label'])] = $column['key']; + } + + $headerRowNumber = null; + + foreach ($sheetRows as $rowNumber => $cells) { + $first = strtolower(trim($cells[0] ?? '')); + + if (isset($metaKeys[$first])) { + $this->result['meta'][$metaKeys[$first]] = trim($cells[1] ?? ''); + + continue; + } + + if ($first === 'nama desa') { + $headerRowNumber = $rowNumber; + break; + } + } + + if ($headerRowNumber === null) { + $this->result['errors'][] = ['row' => 0, 'message' => 'Baris header template tidak ditemukan. Gunakan template yang diunduh dari sistem.']; + + return; + } + + // Petakan kolom header ke key. + $headerCells = $sheetRows[$headerRowNumber]; + $columnMap = []; + foreach ($headerCells as $index => $label) { + $key = $labelToKey[strtolower(trim($label))] ?? null; + if ($key !== null) { + $columnMap[$index] = $key; + } + } + + if (! in_array('desa', $columnMap, true) || ! in_array('nama', $columnMap, true) || ! in_array('lat', $columnMap, true) || ! in_array('lng', $columnMap, true)) { + $this->result['errors'][] = ['row' => $headerRowNumber, 'message' => 'Kolom wajib template tidak lengkap (Nama Desa, Nama Lokasi, Latitude, Longitude).']; + + return; + } + + foreach ($sheetRows as $rowNumber => $cells) { + if ($rowNumber <= $headerRowNumber) { + continue; + } + + $data = []; + $isBlank = true; + foreach ($columnMap as $index => $key) { + $value = trim($cells[$index] ?? ''); + if ($value !== '') { + $isBlank = false; + } + $data[$key] = $value; + } + + if ($isBlank) { + continue; + } + + $data['_row'] = $rowNumber; + $errors = $this->validateRow($data); + + $this->result['rows'][] = $data; + + if ($errors === []) { + $this->result['valid'][] = $data; + } else { + $this->result['errors'][] = ['row' => $rowNumber, 'messages' => $errors]; + } + } + } + + /** + * @param array $data + * @return array + */ + protected function validateRow(array $data): array + { + $errors = []; + + if (trim((string) ($data['desa'] ?? '')) === '') { + $errors[] = 'Nama Desa wajib diisi.'; + } + + $nama = trim((string) ($data['nama'] ?? '')); + + if ($nama === '') { + $errors[] = 'Nama Lokasi wajib diisi.'; + } elseif (mb_strlen($nama) > 50) { + $errors[] = 'Nama Lokasi maksimal 50 karakter.'; + } + + if (! $this->isValidCoordinate($data['lat'] ?? '', Coordinate::MIN_LAT, Coordinate::MAX_LAT)) { + $errors[] = 'Latitude tidak valid (harus berada di wilayah Indonesia '.Coordinate::MIN_LAT.' s.d. '.Coordinate::MAX_LAT.').'; + } + + if (! $this->isValidCoordinate($data['lng'] ?? '', Coordinate::MIN_LNG, Coordinate::MAX_LNG)) { + $errors[] = 'Longitude tidak valid (harus berada di wilayah Indonesia '.Coordinate::MIN_LNG.' s.d. '.Coordinate::MAX_LNG.').'; + } + + return $errors; + } + + protected function isValidCoordinate(string $value, float $min, float $max): bool + { + if ($value === '' || ! is_numeric($value)) { + return false; + } + + $number = (float) $value; + + return $number >= $min && $number <= $max; + } +} diff --git a/app/Rules/ValidKodeDesa.php b/app/Rules/ValidKodeDesa.php new file mode 100644 index 000000000..15167e517 --- /dev/null +++ b/app/Rules/ValidKodeDesa.php @@ -0,0 +1,32 @@ +desa()) + ->contains(fn ($desa) => (string) ($desa->kode_desa ?? '') === (string) $value); + + if (! $exists) { + $fail('Desa yang dipilih tidak valid.'); + } + } +} diff --git a/app/Services/BaseApiService.php b/app/Services/BaseApiService.php index 86b70fc18..3f51b7f88 100644 --- a/app/Services/BaseApiService.php +++ b/app/Services/BaseApiService.php @@ -66,4 +66,73 @@ protected function apiRequest(string $endpoint, array $params = []) return []; } + + /** + * Panggilan API database gabungan generik (GET/POST/PUT/PATCH/DELETE). + * + * Menangani header otentikasi, pengiriman payload, logging, dan + * melempar exception saat respons gagal agar dapat ditangani pemanggil. + * + * @param array $payload + */ + protected function apiCall(string $method, string $endpoint, array $payload = []): mixed + { + $url = str_starts_with($endpoint, 'http') ? $endpoint : $this->baseUrl.$endpoint; + $headers = array_merge($this->header, ['Accept' => 'application/json']); + + try { + $pending = Http::withHeaders($headers); + + $response = match (strtoupper($method)) { + 'GET' => $pending->get($url, $payload), + 'POST' => $pending->post($url, $payload), + 'PUT' => $pending->put($url, $payload), + 'PATCH' => $pending->patch($url, $payload), + 'DELETE' => $pending->delete($url, $payload), + default => throw new \InvalidArgumentException("Metode HTTP '{$method}' tidak didukung."), + }; + + if ($response->failed()) { + throw new \RuntimeException('Gagal memanggil API ('.$response->status().'): '.$response->body()); + } + + return $response->json('data') ?? $response->json(); + } catch (\Throwable $e) { + Log::error('Failed '.strtoupper($method).' request to '.$url.' in '.__FILE__.' '.$e->getMessage()); + + throw $e; + } + } + + /** + * @param array $params + */ + protected function apiGet(string $endpoint, array $params = []): mixed + { + return $this->apiCall('GET', $endpoint, $params); + } + + /** + * @param array $data + */ + protected function apiPost(string $endpoint, array $data = []): mixed + { + return $this->apiCall('POST', $endpoint, $data); + } + + /** + * @param array $data + */ + protected function apiPut(string $endpoint, array $data = []): mixed + { + return $this->apiCall('PUT', $endpoint, $data); + } + + /** + * @param array $data + */ + protected function apiDelete(string $endpoint, array $data = []): mixed + { + return $this->apiCall('DELETE', $endpoint, $data); + } } diff --git a/app/Services/PemetaanService.php b/app/Services/PemetaanService.php index 3bec3541d..66a22608e 100644 --- a/app/Services/PemetaanService.php +++ b/app/Services/PemetaanService.php @@ -2,91 +2,91 @@ namespace App\Services; -use App\Models\Setting; -use Illuminate\Support\Facades\Http; - class PemetaanService extends BaseApiService { - protected $baseUrl; - - protected $setting; - - public function __construct() + public function getAllPoint(array $query = []): array { - parent::__construct(); - $this->setting = Setting::where('key', 'database_gabungan_api_key')->first(); + return $this->apiRequest('/api/v1/point', $query); } - public function getAllPoint(array $query = []) + public function pointLock(array $data, string $id): ?array { - return $this->apiRequest('/api/v1/point', $query); + return $this->apiPut("/api/v1/point/lock/{$id}", $data); } - public function pointLock(array $data, string $id): ?array + public function pointUpdate(array $data, string $id): ?array { - $url = "{$this->baseUrl}/api/v1/point/lock/{$id}"; + return $this->apiPut("/api/v1/point/update/{$id}", $data); + } - $response = Http::withToken($this->setting->value ?? '') - ->acceptJson() - ->put($url, $data); + public function pointStore(array $data): ?array + { + return $this->apiPost('/api/v1/point/store', $data); + } - if ($response->successful()) { - return $response->json('data') ?? null; - } + public function getAllPlan(array $query = []): ?array + { + $data = $this->apiGet('/api/v1/plan', $query); - throw new \Exception('Gagal mengupdate data point: '.$response->status().' - '.$response->body()); + return is_array($data) ? $data : null; } - public function pointUpdate(array $data, string $id): ?array + public function planStore(array $data): ?array { - $url = "{$this->baseUrl}/api/v1/point/update/{$id}"; - - $response = Http::withToken($this->setting->value ?? '') - ->acceptJson() - ->put($url, $data); + return $this->apiPost('/api/v1/plan/store', $data); + } - if ($response->successful()) { - return $response->json('data') ?? null; - } + /** + * Kirim seluruh baris import sekaligus (batch) ke API database gabungan. + * + * @param array $payload + */ + public function planImport(array $payload): array + { + $result = $this->apiPost('/api/v1/plan/import', $payload); - throw new \Exception('Gagal mengupdate data point: '.$response->status().' - '.$response->body()); + return is_array($result) ? $result : []; } - public function pointStore(array $data): ?array + /** + * Hapus beberapa lokasi sekaligus (batch) via API database gabungan. + * Dikirim sebagai query string array `ids[]` pada endpoint `/api/v1/plan/`. + * + * @param array $ids + * @return array + */ + public function planBulkDelete(array $ids): array { - $url = "{$this->baseUrl}/api/v1/point/store"; + $query = implode('&', array_map( + fn (int|string $id): string => 'ids[]='.urlencode((string) $id), + array_values($ids), + )); - $response = Http::withToken($this->setting->value ?? '') - ->acceptJson() - ->post($url, $data); + $result = $this->apiDelete('/api/v1/plan/?'.$query); - if ($response->successful()) { - return $response->json('data') ?? null; - } - - throw new \Exception('Gagal store data point: '.$response->status().' - '.$response->body()); + return is_array($result) ? $result : []; } - public function getAllPlan(array $query = []) + public function planUpdate(array $data, string $id): ?array { - $url = $this->baseUrl.'/api/v1/plan'; + return $this->apiPut("/api/v1/plan/update/{$id}", $data); + } - $response = Http::withHeaders([ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'Authorization' => 'Bearer '.($this->setting->value ?? ''), - ])->get($url, $query); + public function planDelete(string $id): bool + { + $this->apiDelete("/api/v1/plan/hapus/{$id}"); - if ($response->successful()) { - $data = $response->json('data'); + return true; + } - if (is_array($data)) { - return $data; - } + public function planById(string $id): ?array + { + $result = $this->getAllPlan(['filter[id]' => $id]); - return null; + if (is_array($result) && count($result) > 0) { + return (array) $result[0]; } - throw new \Exception('Gagal mengambil data point: '.$response->status()); + return null; } } diff --git a/app/Support/Coordinate.php b/app/Support/Coordinate.php new file mode 100644 index 000000000..f11110e71 --- /dev/null +++ b/app/Support/Coordinate.php @@ -0,0 +1,20 @@ + -
- -
-
- -
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
-
- +
+
-
\ No newline at end of file + diff --git a/resources/views/peta/lokasi/form.blade.php b/resources/views/peta/lokasi/form.blade.php new file mode 100644 index 000000000..cfbe9c953 --- /dev/null +++ b/resources/views/peta/lokasi/form.blade.php @@ -0,0 +1,83 @@ + diff --git a/resources/views/peta/lokasi/import.blade.php b/resources/views/peta/lokasi/import.blade.php new file mode 100644 index 000000000..b921b8713 --- /dev/null +++ b/resources/views/peta/lokasi/import.blade.php @@ -0,0 +1,61 @@ + diff --git a/resources/views/peta/lokasi/import_summary.blade.php b/resources/views/peta/lokasi/import_summary.blade.php new file mode 100644 index 000000000..fc49566db --- /dev/null +++ b/resources/views/peta/lokasi/import_summary.blade.php @@ -0,0 +1,37 @@ +@php + $summary = Session::get('import_summary'); +@endphp + +@if ($summary) +
+
+

Ringkasan Import

+
+
+

+ Total baris: {{ $summary['total'] }} · + Berhasil: {{ $summary['success'] }} · + Gagal: {{ $summary['failed'] }} +

+ + @if (! empty($summary['errors'])) +
+ + + + + + + + @foreach ($summary['errors'] as $error) + + + + @endforeach + +
Keterangan
{{ $error }}
+
+ @endif +
+
+@endif diff --git a/resources/views/peta/lokasi/index.blade.php b/resources/views/peta/lokasi/index.blade.php index eb7d04ded..0e74cb178 100644 --- a/resources/views/peta/lokasi/index.blade.php +++ b/resources/views/peta/lokasi/index.blade.php @@ -11,40 +11,79 @@ @section('content') @include('partials.breadcrumbs') @include('partials.flash_message') - + + @if (Session::has('import_summary')) + @include('peta.lokasi.import_summary') + @endif +
- @include('peta.lokasi.filter') - +
+ @include('peta.lokasi.filter') +
+
+ + + +
+
- @include('peta.lokasi.table') - - + @include('peta.lokasi.table')
+ @include('peta.lokasi.import') + @include('peta.lokasi.form') @endsection + +@include('components.wilayah_filter_js') + @push('css') - @endpush -@section('js') - + @endsection diff --git a/resources/views/peta/lokasi/js/app.blade.php b/resources/views/peta/lokasi/js/app.blade.php new file mode 100644 index 000000000..50cfa2a86 --- /dev/null +++ b/resources/views/peta/lokasi/js/app.blade.php @@ -0,0 +1,373 @@ +(function () { + 'use strict'; + + function boot() { + const $ = window.jQuery; + + if (!$) { + return; + } + + const config = window.lokasiConfig || {}; + const header = @include('layouts.components.header_bearer_api_gabungan'); + + /* ------------------------------------------------ + * Tabel Data Lokasi + * ---------------------------------------------- */ + const lokasiTable = $('#plan').DataTable({ + processing: true, + serverSide: true, + autoWidth: false, + ordering: true, + searchPanes: { + viewTotal: false, + columns: [0], + }, + ajax: { + url: new URL("{{ config('app.databaseGabunganUrl').'/api/v1/plan' }}"), + headers: header, + method: 'get', + data: function (row) { + return { + 'page[size]': row.length, + 'page[number]': (row.start / row.length) + 1, + 'filter[search]': row.search.value, + 'sort': (row.order[0]?.dir === 'asc' ? '' : '-') + row.columns[row.order[0]?.column]?.name, + 'filter[status]': $('#status').val(), + 'filter[point]': $('#point').val(), + 'filter[subpoint]': $('#subpoint').val(), + 'filter[kode_kabupaten]': $('#filter_kabupaten').val(), + 'filter[kode_kecamatan]': $('#filter_kecamatan').val(), + 'filter[kode_desa]': $('#filter_desa').val(), + }; + }, + dataSrc: function (json) { + json.recordsTotal = json.meta.pagination.total; + json.recordsFiltered = json.meta.pagination.total; + + return json.data; + }, + }, + columnDefs: [ + { targets: '_all', className: 'text-nowrap' }, + { targets: [0, 1, 2, 4], orderable: false, searchable: false }, + ], + columns: [ + { + data: null, + orderable: false, + searchable: false, + render: function (data, type, row) { + return ``; + }, + }, + { data: null }, + { + data: null, + name: 'aksi', + searchable: false, + orderable: false, + render: function (data, type, row) { + return ` + + + `; + }, + }, + { data: 'nama', name: 'nama', searchable: true, orderable: true }, + { data: 'nama_desa', name: 'nama_desa', searchable: false, orderable: false }, + { data: 'enabled', name: 'enabled', searchable: false, orderable: true }, + { data: 'jenis', name: 'jenis', searchable: false, orderable: false }, + { data: 'kategori', name: 'kategori', searchable: false, orderable: false }, + ], + order: [[3, 'asc']], + }); + + lokasiTable.on('draw.dt', function () { + const pageInfo = $('#plan').DataTable().page.info(); + lokasiTable.column(1, { page: 'current' }).nodes().each(function (cell, i) { + cell.innerHTML = i + 1 + pageInfo.start; + }); + + $('#check-all').prop('checked', false); + }); + + /* ---------- Filter (mengikuti pola halaman /penduduk) ---------- */ + $('#point').on('change', function () { + const _label = $(this).find('option:selected').text(); + $('#subpoint').val(''); + $('#subpoint').find('optgroup').prop('disabled', 1); + + if ($(this).val()) { + $('#subpoint').find(`optgroup[label="${_label}"]`).prop('disabled', 0); + } + }); + + $('#filter').on('click', function () { + lokasiTable.draw(); + }); + + $('#filter_kabupaten, #filter_kecamatan, #filter_desa').on('change', function () { + lokasiTable.draw(); + }); + + $(document).on('click', '#reset', function (e) { + e.preventDefault(); + $('#status').val('').trigger('change'); + $('#point').val('').trigger('change'); + $('#subpoint').val('').trigger('change'); + $('#filter_kabupaten').val('').trigger('change'); + $('#filter_kecamatan').val('').trigger('change'); + $('#filter_desa').val('').trigger('change'); + lokasiTable.draw(); + }); + + /* ---------- Pilihan Kategori dinamis ---------- */ + function setupKategoriSelect(jenisSelector, kategoriSelector) { + const jenis = $(jenisSelector); + const kategori = $(kategoriSelector); + const daftar = config.jenisKategori || []; + + const refresh = function () { + const selected = (jenis.val() || '').toString(); + + kategori.empty().append(new Option('Pilih Kategori', '')); + + const match = daftar.find(function (item) { + return String(item.id) === selected; + }); + + if (match && Array.isArray(match.children) && match.children.length > 0) { + match.children.forEach(function (child) { + kategori.append(new Option(child.nama, child.id)); + }); + kategori.prop('disabled', false); + } else { + kategori.prop('disabled', true); + } + }; + + jenis.on('change', refresh); + refresh(); + } + + setupKategoriSelect('#import-jenis', '#import-kategori'); + setupKategoriSelect('#lokasi-jenis', '#lokasi-kategori'); + + /* ---------- Import ---------- */ + $('#bt-download-template').on('click', function () { + const jenis = $('#import-jenis').val(); + const kategori = $('#import-kategori').val(); + + if (!jenis || !kategori) { + Swal.fire('Perhatian', 'Pilih Jenis dan Kategori terlebih dahulu.', 'warning'); + + return; + } + + const params = new URLSearchParams({ jenis: jenis, kategori: kategori }); + window.location.href = `${config.urls.template}?${params.toString()}`; + }); + + $('#form-import').on('submit', function (e) { + const jenis = $('#import-jenis').val(); + const kategori = $('#import-kategori').val(); + const file = $('#import-file').val(); + + if (!jenis || !kategori || !file) { + e.preventDefault(); + Swal.fire('Perhatian', 'Lengkapi Jenis, Kategori, dan File template.', 'warning'); + + return; + } + + Swal.fire({ + title: 'Mengimpor', + text: 'Mohon tunggu, data sedang diproses.', + allowOutsideClick: false, + didOpen: function () { + Swal.showLoading(); + }, + }); + }); + + /* ---------- CRUD manual ---------- */ + function resolveJenisKategoriIds(data) { + const daftar = config.jenisKategori || []; + const jenisRaw = String(data.jenis_id ?? data.jenis ?? ''); + const kategoriRaw = String(data.kategori_id ?? data.kategori ?? data.ref_point ?? ''); + + let jenisItem = daftar.find(function (item) { + return String(item.id) === jenisRaw; + }) || daftar.find(function (item) { + return String(item.nama).toLowerCase() === jenisRaw.toLowerCase(); + }); + + if (!jenisItem && kategoriRaw !== '') { + jenisItem = daftar.find(function (item) { + return (item.children || []).some(function (child) { + return String(child.id) === kategoriRaw; + }); + }); + } + + if (!jenisItem) { + return { jenis: jenisRaw, kategori: kategoriRaw }; + } + + const child = (jenisItem.children || []).find(function (item) { + return String(item.id) === kategoriRaw + || String(item.nama).toLowerCase() === kategoriRaw.toLowerCase(); + }); + + return { + jenis: String(jenisItem.id), + kategori: child ? String(child.id) : '', + }; + } + + function resolveKodeDesa(data) { + if (data.kode_desa) { + return String(data.kode_desa); + } + + if (data.config_id) { + const byId = (config.desaList || []).find(function (item) { + return String(item.id) === String(data.config_id); + }); + + if (byId) { + return String(byId.kode_desa); + } + } + + const nama = String(data.nama_desa || '').toLowerCase(); + const match = (config.desaList || []).find(function (item) { + return String(item.nama_desa).toLowerCase() === nama; + }); + + return match ? String(match.kode_desa) : ''; + } + + function resetLokasiForm() { + const desaDefault = (config.desa && config.desa.kode_desa) || ''; + + $('#modal-form-label').text('Tambah Data Lokasi'); + $('#form-lokasi').attr('action', config.urls.store); + $('#lokasi-method').val('POST'); + $('#lokasi-id').val(''); + $('#lokasi-nama').val(''); + $('#lokasi-lat').val(''); + $('#lokasi-lng').val(''); + $('#lokasi-keterangan').val(''); + $('#lokasi-kode-desa').val(desaDefault); + $('#lokasi-jenis').val('').trigger('change'); + } + + $('#bt-tambah').on('click', resetLokasiForm); + + $(document).on('click', '.btn-edit', function () { + const id = $(this).data('id'); + + $.ajax({ + url: `${config.urls.detail}/${id}`, + method: 'GET', + headers: { Accept: 'application/json' }, + success: function (response) { + const data = (response && response.data) ? response.data : {}; + + $('#modal-form-label').text('Ubah Data Lokasi'); + $('#form-lokasi').attr('action', `${config.urls.update}/${id}`); + $('#lokasi-method').val('PUT'); + $('#lokasi-id').val(id); + $('#lokasi-nama').val(data.nama || ''); + $('#lokasi-lat').val(data.lat || ''); + $('#lokasi-lng').val(data.lng || ''); + $('#lokasi-keterangan').val(data.keterangan ?? data.desk ?? ''); + $('#lokasi-kode-desa').val(resolveKodeDesa(data)); + + const jenisKategori = resolveJenisKategoriIds(data); + + $('#lokasi-jenis').val(jenisKategori.jenis).trigger('change'); + $('#lokasi-kategori').val(jenisKategori.kategori).trigger('change'); + + $('#modal-form').modal('show'); + }, + error: function () { + Swal.fire('Error', 'Gagal mengambil data lokasi.', 'error'); + }, + }); + }); + + $(document).on('click', 'button.hapus', function () { + const id = $(this).data('id'); + + Swal.fire({ + title: 'Hapus', + text: 'Apakah anda yakin menghapus data ini?', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Hapus', + cancelButtonText: 'Batal', + }).then(function (result) { + if (!result.isConfirmed) { + return; + } + + const form = $('
', { method: 'POST', action: `${config.urls.destroy}/${id}` }); + form.append($('', { type: 'hidden', name: '_token', value: config.csrf })); + form.append($('', { type: 'hidden', name: '_method', value: 'DELETE' })); + $('body').append(form); + form.trigger('submit'); + }); + }); + + /* ---------- Hapus massal (halaman aktif) ---------- */ + $('#check-all').on('change', function () { + $('.row-check').prop('checked', $(this).prop('checked')); + }); + + $('#bt-bulk-delete').on('click', function () { + const ids = $('.row-check:checked') + .map(function () { + return $(this).val(); + }) + .get() + .filter(Boolean); + + if (ids.length === 0) { + Swal.fire('Perhatian', 'Pilih minimal satu data lokasi.', 'warning'); + + return; + } + + Swal.fire({ + title: 'Hapus', + text: `Hapus ${ids.length} data lokasi terpilih?`, + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Hapus', + cancelButtonText: 'Batal', + }).then(function (result) { + if (!result.isConfirmed) { + return; + } + + const form = $('', { method: 'POST', action: config.urls.bulkDestroy }); + form.append($('', { type: 'hidden', name: '_token', value: config.csrf })); + form.append($('', { type: 'hidden', name: '_method', value: 'DELETE' })); + ids.forEach(function (id) { + form.append($('', { type: 'hidden', name: 'ids[]', value: id })); + }); + $('body').append(form); + form.trigger('submit'); + }); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/resources/views/peta/lokasi/js/data.blade.php b/resources/views/peta/lokasi/js/data.blade.php deleted file mode 100644 index adb795ec0..000000000 --- a/resources/views/peta/lokasi/js/data.blade.php +++ /dev/null @@ -1,91 +0,0 @@ -const header = @include('layouts.components.header_bearer_api_gabungan'); -var url = new URL("{{ config('app.databaseGabunganUrl').'/api/v1/plan' }}"); -var point = $('#plan').DataTable({ -processing: true, -serverSide: true, -autoWidth: false, -ordering: true, -searchPanes: { - viewTotal: false, - columns: [0] -}, -ajax: { - url: url, - headers: header, - method: 'get', - data: function(row) { - return { - "page[size]": row.length, - "page[number]": (row.start / row.length) + 1, - "filter[search]": row.search.value, - "sort": (row.order[0]?.dir === "asc" ? "" : "-") + row.columns[row.order[0]?.column] - ?.name, - "filter[status]": $("#status").val(), - "filter[point]": $("#point").val(), - "filter[subpoint]": $("#subpoint").val(), - }; - }, - dataSrc: function(json) { - json.recordsTotal = json.meta.pagination.total - json.recordsFiltered = json.meta.pagination.total - - return json.data - }, -}, -columnDefs: [{ - targets: '_all', - className: 'text-nowrap', - }, - { - targets: [0, 1, 2, 3], - orderable: false, - searchable: false, - }, -], -columns: [{ - data: null, - }, - { - data: 'aksi', - name: 'aksi', - searchable: false, - orderable: false - }, - { - data: 'nama', - name: 'nama', - searchable: true, - orderable: true - }, - { - data: 'enabled', - name: 'enabled', - searchable: false, - orderable: true - }, - { - data: 'jenis', - name: 'jenis', - searchable: false, - orderable: false - }, - { - data: 'kategori', - name: 'kategori', - searchable: false, - orderable: false - }, -], -order: [ - [2, 'asc'] -] -}) - -point.on('draw.dt', function() { -var PageInfo = $('#plan').DataTable().page.info(); -point.column(0, { - page: 'current' -}).nodes().each(function(cell, i) { - cell.innerHTML = i + 1 + PageInfo.start; -}); -}); \ No newline at end of file diff --git a/resources/views/peta/lokasi/js/filter.blade.php b/resources/views/peta/lokasi/js/filter.blade.php deleted file mode 100644 index e1c0feb2d..000000000 --- a/resources/views/peta/lokasi/js/filter.blade.php +++ /dev/null @@ -1,18 +0,0 @@ -$('#point').change(function() { - let _label = $(this).find('option:selected').text() - $('#subpoint').val('') - $('#subpoint').find('optgroup').prop('disabled', 1) - if ($(this).val()) { - $('#subpoint').closest('div').show() - $('#subpoint').find(`optgroup[label="${_label}"]`).prop('disabled', 0) - } else { - $('#subpoint').closest('div').hide() - } - $('#subpoint').select2() -}) - -$('#subpoint').closest('div').hide() - -$('#subpoint, #point, #status').change(function() { - point.draw() -}) \ No newline at end of file diff --git a/resources/views/peta/lokasi/table.blade.php b/resources/views/peta/lokasi/table.blade.php index ee8b40a89..1922558bc 100644 --- a/resources/views/peta/lokasi/table.blade.php +++ b/resources/views/peta/lokasi/table.blade.php @@ -1,12 +1,14 @@ + + -
No Aksi LokasiDesa Aktif Jenis Kategori
\ No newline at end of file + diff --git a/routes/web.php b/routes/web.php index 0c60c0c59..1ad837332 100644 --- a/routes/web.php +++ b/routes/web.php @@ -408,6 +408,13 @@ Route::get('/plan/{parent?}', [PlanController::class, 'index'])->name('plan'); Route::get('/plan/ajax_lokasi_maps/{parrent}/{id}', [PlanController::class, 'ajax_lokasi_maps'])->name('plan.ajax_lokasi_maps'); Route::get('/show/plan/ajax_lokasi_maps/{parrent}/{id}', [PlanController::class, 'show_ajax_lokasi_maps']); + Route::post('/plan/import', [PlanController::class, 'import'])->name('plan.import'); + Route::get('/plan/import/template', [PlanController::class, 'downloadTemplate'])->name('plan.import.template'); + Route::get('/plan/detail/{id}', [PlanController::class, 'detail'])->name('plan.detail'); + Route::post('/plan/store', [PlanController::class, 'store'])->name('plan.store'); + Route::put('/plan/update/{id}', [PlanController::class, 'update'])->name('plan.update'); + Route::delete('/plan/hapus', [PlanController::class, 'bulkDestroy'])->name('plan.destroy.bulk'); + Route::delete('/plan/hapus/{id}', [PlanController::class, 'destroy'])->name('plan.destroy'); Route::resource('desa', DesaController::class)->only(['index']); Route::get('desa/cetak', [DesaController::class, 'cetak']); diff --git a/tests/Browser/SmokeLokasiImportTest.php b/tests/Browser/SmokeLokasiImportTest.php new file mode 100644 index 000000000..38d8fbd37 --- /dev/null +++ b/tests/Browser/SmokeLokasiImportTest.php @@ -0,0 +1,45 @@ +group('pengaturan-peta'); + +beforeEach(function () { + $this->user = SessionState::loginAdminUser(); +}); + +afterEach(function () { + SessionState::clear(); +}); + +it('opens the lokasi page with import controls', function () { + $page = SessionState::loginAndNavigate($this->user, '/plan') + ->assertPathIs('/plan') + ->assertSee('Data Lokasi') + ->assertVisible('@bt-import') + ->assertVisible('@bt-tambah'); + + ScreenshotHelper::saveIfEnabled($page, 'lokasi-import-controls'); +}); + +it('opens the import modal with jenis, kategori and template controls', function () { + $page = SessionState::loginAndNavigate($this->user, '/plan') + ->assertVisible('@bt-import') + ->click('@bt-import') + ->assertVisible('@import-jenis') + ->assertVisible('@import-kategori') + ->assertVisible('@bt-download-template') + ->assertVisible('@import-file') + ->assertVisible('@bt-submit-import'); + + ScreenshotHelper::saveIfEnabled($page, 'lokasi-import-modal'); +}); + +it('has no javascript errors on the lokasi page', function () { + $page = SessionState::loginAndNavigate($this->user, '/plan') + ->assertPathIs('/plan') + ->assertNoJavaScriptErrors(); + + ScreenshotHelper::saveIfEnabled($page, 'lokasi-import-no-errors'); +}); diff --git a/tests/Browser/SmokeLokasiTest.php b/tests/Browser/SmokeLokasiTest.php index b101d4cfb..d55f06ba5 100644 --- a/tests/Browser/SmokeLokasiTest.php +++ b/tests/Browser/SmokeLokasiTest.php @@ -23,8 +23,15 @@ it('displays filter fields', function () { $page = SessionState::loginAndNavigate($this->user, '/plan') + ->assertVisible('@bt-toggle-filter') + ->assertVisible('@bt-bulk-delete') + ->click('@bt-toggle-filter') ->assertVisible('@filter-status') - ->assertVisible('@filter-point'); + ->assertVisible('@filter-point') + ->assertVisible('@filter-kabupaten') + ->assertVisible('@filter-kecamatan') + ->assertVisible('@filter-desa') + ->assertVisible('@bt-filter'); ScreenshotHelper::saveIfEnabled($page, 'lokasi-filter-fields'); }); diff --git a/tests/Browser/fixtures/plan-lokasi.json b/tests/Browser/fixtures/plan-lokasi.json index f0aa9794d..6c4e104f2 100644 --- a/tests/Browser/fixtures/plan-lokasi.json +++ b/tests/Browser/fixtures/plan-lokasi.json @@ -4,6 +4,7 @@ "type": "plan", "id": "1", "nama": "Kantor Desa", + "nama_desa": "Desa Contoh", "enabled": "YA", "jenis": "Gedung", "kategori": "Pemerintahan", @@ -13,6 +14,7 @@ "type": "plan", "id": "2", "nama": "Puskesmas Pembantu", + "nama_desa": "Desa Contoh", "enabled": "YA", "jenis": "Gedung", "kategori": "Kesehatan", diff --git a/tests/Feature/Peta/LokasiImportTest.php b/tests/Feature/Peta/LokasiImportTest.php new file mode 100644 index 000000000..f8037eee7 --- /dev/null +++ b/tests/Feature/Peta/LokasiImportTest.php @@ -0,0 +1,595 @@ + 'https://api.example.com']); + + session()->put('desa', [ + 'kode_desa' => self::KODE_DESA_A, + 'nama_desa' => 'Desa Contoh', + ]); + } + + /** + * Pasang fake API sekali per test memakai satu closure agar urutan stub tidak ambigu. + * + * @param array> $existingPlans + * @param array> $desaAttributes + */ + private function fakeApi(array $existingPlans = [], array $desaAttributes = [], bool $storeSucceeds = true): void + { + $desaAttributes = $desaAttributes ?: [[ + 'kode_desa' => self::KODE_DESA_A, + 'nama_desa' => 'Desa Contoh', + 'kode_kecamatan' => '50.01.01', + 'nama_kecamatan' => 'Contoh', + ]]; + + Http::fake(function ($request) use ($existingPlans, $desaAttributes, $storeSucceeds) { + $url = $request->url(); + $method = strtoupper($request->method()); + + if (str_contains($url, '/api/v1/config/desa')) { + return Http::response([ + 'data' => collect($desaAttributes)->values()->map(fn ($attrs, $i) => [ + 'id' => (string) ($i + 1), + 'attributes' => $attrs, + ])->all(), + ], 200); + } + + if (str_contains($url, '/api/v1/point')) { + return Http::response([ + 'data' => [ + ['id' => '1', 'nama' => 'Gedung', 'children' => [['id' => '10', 'nama' => 'Pemerintahan']]], + ], + ], 200); + } + + if (str_contains($url, '/api/v1/plan/import')) { + $rows = (array) ($request->data()['data'] ?? []); + + return Http::response([ + 'success' => true, + 'summary' => ['success' => count($rows), 'failed' => 0], + ], 200); + } + + if (str_contains($url, '/api/v1/plan/store')) { + return Http::response($storeSucceeds ? ['data' => ['id' => '99']] : ['message' => 'gagal'], $storeSucceeds ? 200 : 500); + } + + if (str_contains($url, '/api/v1/plan/hapus')) { + return Http::response(['success' => true], 200); + } + + if ($method === 'DELETE' && str_contains($url, '/api/v1/plan')) { + return Http::response(['success' => true], 200); + } + + if (str_contains($url, '/api/v1/plan')) { + return Http::response(['data' => $existingPlans], 200); + } + + return Http::response([], 200); + }); + } + + private function upload(string $path): UploadedFile + { + return new UploadedFile( + $path, + 'template.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + null, + true + ); + } + + #[Test] + public function template_export_contains_desa_reference_sheet_and_per_row_header(): void + { + $path = storage_path('app/templates/testing_export.xlsx'); + @mkdir(dirname($path), 0775, true); + + (new LokasiTemplateExport( + [['kode_desa' => self::KODE_DESA_A, 'nama_desa' => 'Desa Contoh']], + 'Gedung', + 'Pemerintahan', + ))->write($path); + + $this->assertFileExists($path); + + $zip = new ZipArchive; + $this->assertTrue($zip->open($path) === true); + $workbook = (string) $zip->getFromName('xl/workbook.xml'); + $sheet1 = (string) $zip->getFromName('xl/worksheets/sheet1.xml'); + + $this->assertStringContainsString('Daftar Desa', $workbook); + $this->assertStringContainsString('dataValidations', $sheet1); + $this->assertStringContainsString('Nama Desa', $sheet1); + $this->assertStringContainsString('Nama Lokasi', $sheet1); + $this->assertStringContainsString('sqref="A7:A1006"', $sheet1); + $this->assertStringContainsString('$B$2:$B$2', $sheet1); + $this->assertStringContainsString('Desa Contoh', (string) $zip->getFromName('xl/worksheets/sheet2.xml')); + $zip->close(); + + $import = new LokasiImport; + $result = $import->process($path); + + $this->assertSame('Gedung', $result['meta']['jenis']); + $this->assertSame('Pemerintahan', $result['meta']['kategori']); + + @unlink($path); + } + + #[Test] + public function import_rejects_rows_with_invalid_coordinates_and_flags_valid_rows(): void + { + $file = $this->makeXlsxFile([ + ['Nama Desa', 'Nama Lokasi', 'Latitude', 'Longitude', 'Keterangan'], + ['Desa Contoh', 'Kantor Desa', '-6.2', '106.8', 'pusat'], + ['Desa Contoh', 'Balai Desa', 'abc', '106.9', ''], + ['Desa Contoh', 'Posyandu', '-6.3', '200', ''], + ]); + + $import = new LokasiImport; + $result = $import->process($file); + + $this->assertCount(3, $result['rows']); + $this->assertCount(1, $result['valid']); + $this->assertCount(2, $result['errors']); + + @unlink($file); + } + + #[Test] + public function import_rejects_nama_longer_than_50_characters(): void + { + $file = $this->makeXlsxFile([ + ['Nama Desa', 'Nama Lokasi', 'Latitude', 'Longitude', 'Keterangan'], + ['Desa Contoh', str_repeat('A', 51), '-6.2', '106.8', ''], + ['Desa Contoh', 'Balai Desa', '-6.3', '106.7', ''], + ]); + + $import = new LokasiImport; + $result = $import->process($file); + + $this->assertCount(2, $result['rows']); + $this->assertCount(1, $result['valid']); + $this->assertCount(1, $result['errors']); + + @unlink($file); + } + + #[Test] + public function import_rejects_row_with_empty_desa(): void + { + $file = $this->makeXlsxFile([ + ['Nama Desa', 'Nama Lokasi', 'Latitude', 'Longitude', 'Keterangan'], + ['', 'Kantor Desa', '-6.2', '106.8', ''], + ]); + + $import = new LokasiImport; + $result = $import->process($file); + + $this->assertCount(1, $result['rows']); + $this->assertCount(0, $result['valid']); + $this->assertCount(1, $result['errors']); + + @unlink($file); + } + + #[Test] + public function import_rejects_coordinates_outside_indonesia(): void + { + $file = $this->makeXlsxFile([ + ['Nama Desa', 'Nama Lokasi', 'Latitude', 'Longitude', 'Keterangan'], + ['Desa Contoh', 'Luar', '10', '100', ''], + ['Desa Contoh', 'Dalam', '-6.2', '106.8', ''], + ]); + + $import = new LokasiImport; + $result = $import->process($file); + + $this->assertCount(2, $result['rows']); + $this->assertCount(1, $result['valid']); + $this->assertCount(1, $result['errors']); + + @unlink($file); + } + + #[Test] + public function import_rejects_unknown_desa_name(): void + { + $this->fakeApi(); + + $file = $this->makeXlsxFile([ + ['Nama Desa', 'Nama Lokasi', 'Latitude', 'Longitude', 'Keterangan'], + ['Desa Tidak Ada', 'Kantor Desa', '-6.2', '106.8', ''], + ]); + + $response = $this->post(route('plan.import'), [ + 'jenis' => '1', + 'kategori' => '10', + 'file' => $this->upload($file), + ]); + + $response->assertRedirect(route('plan')); + $response->assertSessionHas('import_summary', function (array $summary) { + return $summary['success'] === 0 && $summary['failed'] === 1; + }); + + @unlink($file); + } + + #[Test] + public function import_rejects_ambiguous_desa_name(): void + { + $this->fakeApi([], [ + ['kode_desa' => '50.01.01.2001', 'nama_desa' => 'Desa Sama'], + ['kode_desa' => '50.01.02.2001', 'nama_desa' => 'Desa Sama'], + ]); + + $file = $this->makeXlsxFile([ + ['Nama Desa', 'Nama Lokasi', 'Latitude', 'Longitude', 'Keterangan'], + ['Desa Sama', 'Kantor Desa', '-6.2', '106.8', ''], + ]); + + $response = $this->post(route('plan.import'), [ + 'jenis' => '1', + 'kategori' => '10', + 'file' => $this->upload($file), + ]); + + $response->assertRedirect(route('plan')); + $response->assertSessionHas('import_summary', function (array $summary) { + return $summary['success'] === 0 && $summary['failed'] === 1; + }); + + @unlink($file); + } + + #[Test] + public function import_stores_valid_multi_desa_rows_via_batch_api(): void + { + $this->fakeApi([], [ + ['kode_desa' => self::KODE_DESA_A, 'nama_desa' => 'Desa A'], + ['kode_desa' => self::KODE_DESA_B, 'nama_desa' => 'Desa B'], + ]); + + $file = $this->makeXlsxFile([ + ['Nama Desa', 'Nama Lokasi', 'Latitude', 'Longitude', 'Keterangan'], + ['Desa A', 'Kantor Desa', '-6.2', '106.8', 'pusat'], + ['Desa B', 'Posyandu', '-6.3', '106.7', ''], + ]); + + $response = $this->post(route('plan.import'), [ + 'jenis' => '1', + 'kategori' => '10', + 'file' => $this->upload($file), + ]); + + $response->assertRedirect(route('plan')); + $response->assertSessionHas('import_summary', function (array $summary) { + return $summary['total'] === 2 && $summary['success'] === 2 && $summary['failed'] === 0; + }); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/api/v1/plan/import')) { + return false; + } + + $data = (array) ($request->data()['data'] ?? []); + + return count($data) === 2 + && $data[0]['config_id'] === 1 + && $data[0]['ref_point'] === 10 + && $data[0]['nama'] === 'Kantor Desa' + && $data[0]['desk'] === 'pusat' + && $data[0]['lat'] === '-6.2' + && $data[0]['lng'] === '106.8' + && $data[1]['config_id'] === 2 + && $data[1]['ref_point'] === 10 + && $data[1]['nama'] === 'Posyandu'; + }); + + @unlink($file); + } + + #[Test] + public function import_is_insert_only_and_rejects_duplicate_names_per_desa(): void + { + $this->fakeApi([ + ['id' => '5', 'nama' => 'Kantor Desa'], + ]); + + $file = $this->makeXlsxFile([ + ['Nama Desa', 'Nama Lokasi', 'Latitude', 'Longitude', 'Keterangan'], + ['Desa Contoh', 'Kantor Desa', '-6.2', '106.8', ''], + ['Desa Contoh', 'Posyandu', '-6.3', '106.7', ''], + ]); + + $response = $this->post(route('plan.import'), [ + 'jenis' => '1', + 'kategori' => '10', + 'file' => $this->upload($file), + ]); + + $response->assertSessionHas('import_summary', function (array $summary) { + return $summary['success'] === 1 && $summary['failed'] === 1; + }); + + @unlink($file); + } + + #[Test] + public function download_template_requires_jenis_and_kategori(): void + { + $this->fakeApi(); + + $response = $this->get(route('plan.import.template')); + + $response->assertSessionHasErrors(['jenis', 'kategori']); + } + + #[Test] + public function download_template_returns_xlsx_without_desa_param(): void + { + $this->fakeApi(); + + $response = $this->get(route('plan.import.template', [ + 'jenis' => '1', + 'kategori' => '10', + ])); + + $response->assertOk(); + $this->assertStringContainsString('spreadsheetml', (string) $response->headers->get('content-type')); + } + + #[Test] + public function store_manual_lokasi_sends_payload_to_api(): void + { + $this->fakeApi(); + + $response = $this->post(route('plan.store'), [ + 'jenis' => '1', + 'kategori' => '10', + 'kode_desa' => self::KODE_DESA_A, + 'nama' => 'Kantor Baru', + 'lat' => '-6.1', + 'lng' => '106.9', + 'keterangan' => 'manual', + ]); + + $response->assertRedirect(route('plan')); + $response->assertSessionHas('success'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/api/v1/plan/store') + && $request['nama'] === 'Kantor Baru' + && (int) $request['config_id'] === 1 + && (int) $request['ref_point'] === 10 + && $request['desk'] === 'manual' + && $request['lat'] === '-6.1' + && $request['lng'] === '106.9'; + }); + } + + #[Test] + public function edit_detail_returns_plan_data_for_modal(): void + { + $this->fakeApi([ + [ + 'id' => '5', + 'nama' => 'Kantor Lama', + 'desk' => 'lama', + 'lat' => '-6.2', + 'lng' => '106.8', + 'ref_point' => 10, + 'kode_desa' => self::KODE_DESA_A, + 'jenis' => 'Gedung', + 'kategori' => 'Pemerintahan', + ], + ]); + + $response = $this->getJson(route('plan.detail', ['id' => '5'])); + + $response->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('data.nama', 'Kantor Lama') + ->assertJsonPath('data.id', '5'); + } + + #[Test] + public function edit_detail_returns_not_found_for_missing_plan(): void + { + $this->fakeApi(); + + $response = $this->getJson(route('plan.detail', ['id' => '99'])); + + $response->assertNotFound()->assertJsonPath('success', false); + } + + #[Test] + public function update_lokasi_sends_payload_to_api(): void + { + $this->fakeApi([ + ['id' => '5', 'nama' => 'Kantor Lama', 'kode_desa' => self::KODE_DESA_A, 'ref_point' => '10'], + ]); + + $response = $this->post(route('plan.update', ['id' => '5']), [ + '_method' => 'PUT', + 'jenis' => '1', + 'kategori' => '10', + 'kode_desa' => self::KODE_DESA_A, + 'nama' => 'Kantor Baru', + 'lat' => '-6.1', + 'lng' => '106.9', + 'keterangan' => 'diperbarui', + ]); + + $response->assertRedirect(route('plan')); + $response->assertSessionHas('success'); + + Http::assertSent(function ($request) { + return $request->method() === 'PUT' + && str_contains($request->url(), '/api/v1/plan/update/5') + && $request['nama'] === 'Kantor Baru' + && (int) $request['config_id'] === 1 + && (int) $request['ref_point'] === 10 + && $request['desk'] === 'diperbarui' + && $request['lat'] === '-6.1' + && $request['lng'] === '106.9'; + }); + } + + #[Test] + public function store_manual_lokasi_validates_coordinates(): void + { + $this->fakeApi(); + + $response = $this->post(route('plan.store'), [ + 'jenis' => '1', + 'kategori' => '10', + 'kode_desa' => self::KODE_DESA_A, + 'nama' => 'Kantor', + 'lat' => '200', + 'lng' => '106.9', + ]); + + $response->assertSessionHasErrors('lat'); + } + + #[Test] + public function store_manual_lokasi_rejects_coordinates_outside_indonesia(): void + { + $this->fakeApi(); + + $response = $this->post(route('plan.store'), [ + 'jenis' => '1', + 'kategori' => '10', + 'kode_desa' => self::KODE_DESA_A, + 'nama' => 'Kantor Luar', + 'lat' => '10.5', + 'lng' => '106.9', + ]); + + $response->assertSessionHasErrors('lat'); + } + + #[Test] + public function bulk_destroy_lokasi_sends_single_delete_with_ids_array(): void + { + $this->fakeApi(); + + $response = $this->delete(route('plan.destroy.bulk'), [ + 'ids' => ['1', '2'], + ]); + + $response->assertRedirect(route('plan')); + $response->assertSessionHas('success'); + + Http::assertSent(function ($request) { + if ($request->method() !== 'DELETE' || ! str_contains($request->url(), '/api/v1/plan/')) { + return false; + } + + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return ($query['ids'] ?? null) === ['1', '2']; + }); + } + + #[Test] + public function bulk_destroy_lokasi_requires_ids(): void + { + $this->fakeApi(); + + $response = $this->delete(route('plan.destroy.bulk'), []); + + $response->assertSessionHasErrors('ids'); + } + + #[Test] + public function destroy_lokasi_calls_delete_api(): void + { + $this->fakeApi(); + + $response = $this->delete(route('plan.destroy', ['id' => '5'])); + + $response->assertRedirect(route('plan')); + $response->assertSessionHas('success'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v1/plan/hapus/5')); + } + + #[Test] + public function plan_page_renders_import_filter_and_manual_controls(): void + { + $this->fakeApi(); + + $response = $this->get(route('plan')); + + $response->assertOk(); + $response->assertSee('data-testid="bt-import"', false); + $response->assertSee('data-testid="bt-tambah"', false); + $response->assertSee('data-testid="import-jenis"', false); + $response->assertSee('data-testid="import-kategori"', false); + $response->assertSee('data-testid="bt-download-template"', false); + $response->assertSee('data-testid="import-file"', false); + $response->assertSee('data-testid="lokasi-kode-desa"', false); + $response->assertSee('data-testid="filter-kabupaten"', false); + $response->assertSee('data-testid="filter-kecamatan"', false); + $response->assertSee('data-testid="filter-desa"', false); + $response->assertSee('data-testid="bt-bulk-delete"', false); + } + + /** + * Buat file xlsx sementara: baris konteks + header per-baris + baris data. + * + * @param array> $rows + */ + private function makeXlsxFile(array $rows): string + { + $path = tempnam(sys_get_temp_dir(), 'lokasi_').'.xlsx'; + + $writer = new Writer; + $writer->openToFile($path); + + $writer->addRow(Row::fromValues(['Jenis Lokasi', 'Gedung'])); + $writer->addRow(Row::fromValues(['Kategori Lokasi', 'Pemerintahan'])); + $writer->addRow(Row::fromValues([''])); + + foreach ($rows as $row) { + $writer->addRow(Row::fromValues($row)); + } + + $writer->close(); + + return $path; + } +}