This commit is contained in:
2026-06-03 13:54:48 +07:00
18 changed files with 3490 additions and 5 deletions
@@ -118,11 +118,13 @@ class TanamanController extends Controller
'latitude' => ['nullable', 'numeric', 'between:-90,90', 'required_with:longitude'],
'longitude' => ['nullable', 'numeric', 'between:-180,180', 'required_with:latitude'],
'kondisi_tanaman' => ['nullable', 'string', new Enum(KondisiTanaman::class)],
'uid' => ['required', 'string'],
]);
$tanaman = Tanaman::create([
...$validated,
'profile_id' => $profile->id,
'uid' => $validated['uid'] ?? $request->input('uid'),
]);
return (new TanamanResource($tanaman))
@@ -10,6 +10,7 @@ use App\Enums\Role;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UserController extends Controller
{
@@ -70,6 +71,7 @@ class UserController extends Controller
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'username' => ['required', 'string', 'max:255', 'unique:users,name'],
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
@@ -90,14 +92,17 @@ class UserController extends Controller
$user = User::create([
'name' => $request->name,
'username' => $request->username,
'email' => $request->email,
'password' => Hash::make($request->password),
'role' => $request->role,
'uid' => Str::random(10),
'created_by' => auth()->id(),
]);
Profile::create([
'user_id' => $user->id,
'user_uid' => $user->uid,
'role' => $request->role,
'nama' => $request->name,
'email' => $request->email,
@@ -44,6 +44,16 @@ class VarietasController extends Controller
));
}
public function getByJenisKomoditas($jenisKomoditasId)
{
$varietas = Varietas::where('jenis_komoditas_id', $jenisKomoditasId)->get();
return response()->json([
'status' => 'success',
'data' => $varietas,
]);
}
public function store(Request $request)
{
$komoditasTable = (new Komoditas)->getTable();
@@ -10,6 +10,7 @@ use App\Models\Pertanian\Adat;
use App\Models\Wilayah\Provinsi;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class LahanController extends Controller
{
@@ -76,6 +77,25 @@ class LahanController extends Controller
return view('produktivitas.lahan.index', compact('lahan', 'provinsi', 'profiles', 'adat', 'kabupaten', 'kecamatan', 'desa'));
}
public function getByProfile(Request $request, $profileId)
{
$desaKelurahanId = $request->get('desa_kelurahan_id');
$lahan = Lahan::with('profile')
->where('profile_id', $profileId)
->when($desaKelurahanId, function ($query) use ($desaKelurahanId) {
$query->where('desa_kelurahan_id', $desaKelurahanId);
})
->orderBy('created_at', 'desc')
->get(['id', 'nama', 'path', 'profile_id', 'desa_kelurahan_id']);
return response()->json([
'status' => 'success',
'data' => $lahan,
]);
}
public function create()
{
$provinsi = Provinsi::orderBy('nama', 'asc')->get();
@@ -113,7 +133,11 @@ class LahanController extends Controller
}
}
$userUID = Profile::where('id', $request->profile_id)->value('user_uid');
$userUID = substr($userUID, 2);
Lahan::create([
'uid' => 'LH' . $userUID . '-' . strtoupper(Str::random(10)),
'nama' => $request->nama,
'status_kepemilikan' => $request->status_kepemilikan,
'profile_id' => $request->profile_id,
@@ -0,0 +1,421 @@
<?php
namespace App\Http\Controllers\Web\Produktivitas;
use App\Http\Controllers\Controller;
use App\Models\Pertanian\Tanaman;
use App\Models\Profile;
use App\Models\Pertanian\Komoditas;
use App\Models\Master\JenisKomoditas;
use App\Models\Master\Varietas;
use App\Models\Pertanian\Lahan;
use App\Models\Wilayah\Provinsi;
use App\Models\Wilayah\KabupatenKota;
use App\Models\Wilayah\Kecamatan;
use App\Models\Wilayah\DesaKelurahan;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class MapTanamanController extends Controller
{
public function index(Request $request)
{
$perPage = $request->get('per_page', 10);
$search = $request->get('search');
$tanaman = Tanaman::query()
->select([
DB::raw('MIN(id) as id'),
'profile_id',
'lahan_id',
'komoditas_id',
'jenis_komoditas_id',
'varietas_id',
DB::raw('COUNT(*) as total_tanaman'),
DB::raw("SUM(CASE WHEN kondisi_tanaman = 'sehat' THEN 1 ELSE 0 END) as total_sehat"),
DB::raw("SUM(CASE WHEN kondisi_tanaman = 'sakit' THEN 1 ELSE 0 END) as total_sakit"),
DB::raw("SUM(CASE WHEN kondisi_tanaman = 'mati' THEN 1 ELSE 0 END) as total_mati"),
DB::raw('MAX(created_at) as max_created_at'),
])
->with([
'profile',
'lahan.desaKelurahan.kecamatan.kabupatenkota.provinsi',
'komoditas',
'jenisKomoditas',
'varietas',
])
->when($search, function ($query) use ($search) {
$query->where(function ($q) use ($search) {
$q->whereHas('profile', fn ($q) => $q->where('nama', 'ILIKE', "%{$search}%"))
->orWhereHas('lahan', fn ($q) => $q->where('nama', 'ILIKE', "%{$search}%"))
->orWhereHas('komoditas', fn ($q) => $q->where('nama', 'ILIKE', "%{$search}%"))
->orWhereHas('jenisKomoditas', fn ($q) => $q->where('nama', 'ILIKE', "%{$search}%"))
->orWhereHas('varietas', fn ($q) => $q->where('nama', 'ILIKE', "%{$search}%"))
->orWhereHas('lahan.desaKelurahan', fn ($q) => $q->where('nama', 'ILIKE', "%{$search}%"))
->orWhereHas('lahan.desaKelurahan.kecamatan', fn ($q) => $q->where('nama', 'ILIKE', "%{$search}%"))
->orWhereHas('lahan.desaKelurahan.kecamatan.kabupatenkota', fn ($q) => $q->where('nama', 'ILIKE', "%{$search}%"))
->orWhereHas('lahan.desaKelurahan.kecamatan.kabupatenkota.provinsi', fn ($q) => $q->where('nama', 'ILIKE', "%{$search}%"));
});
})
->groupBy([
'profile_id',
'lahan_id',
'komoditas_id',
'jenis_komoditas_id',
'varietas_id',
])
->orderByDesc('max_created_at')
->paginate($perPage);
return view('produktivitas.mapTanaman.index', compact('tanaman'));
}
private function getTanamanGroupByRepresentativeId($id)
{
$representative = Tanaman::findOrFail($id);
$points = Tanaman::with([
'profile',
'lahan.desaKelurahan.kecamatan.kabupatenkota.provinsi',
'komoditas',
'jenisKomoditas',
'varietas',
])
->where('profile_id', $representative->profile_id)
->where('lahan_id', $representative->lahan_id)
->where('komoditas_id', $representative->komoditas_id)
->where('jenis_komoditas_id', $representative->jenis_komoditas_id)
->where('varietas_id', $representative->varietas_id)
->orderBy('id')
->get();
return [
'representative' => $representative,
'points' => $points,
];
}
public function create()
{
$provinsi = Provinsi::select('id', 'nama', 'path')
->orderBy('nama')
->get();
$komoditas = Komoditas::orderBy('nama')->get();
$profiles = Profile::orderBy('nama')->get();
return view('produktivitas.mapTanaman.add', compact(
'provinsi',
'komoditas',
'profiles'
));
}
public function store(Request $request)
{
$desaKelurahanTable = (new DesaKelurahan)->getTable();
$profileTable = (new Profile)->getTable();
$lahanTable = (new Lahan)->getTable();
$komoditasTable = (new Komoditas)->getTable();
$jenisKomoditasTable = (new JenisKomoditas)->getTable();
$varietasTable = (new Varietas)->getTable();
$validated = $request->validate([
'desa_kelurahan_id' => [
'required',
'integer',
Rule::exists($desaKelurahanTable, 'id'),
],
'profile_id' => [
'required',
'integer',
Rule::exists($profileTable, 'id'),
],
'lahan_id' => [
'required',
'integer',
Rule::exists($lahanTable, 'id')
->where('profile_id', $request->profile_id)
->where('desa_kelurahan_id', $request->desa_kelurahan_id),
],
'komoditas_id' => [
'required',
'integer',
Rule::exists($komoditasTable, 'id'),
],
'jenis_komoditas_id' => [
'required',
'integer',
Rule::exists($jenisKomoditasTable, 'id')
->where('komoditas_id', $request->komoditas_id),
],
'varietas_id' => [
'required',
'integer',
Rule::exists($varietasTable, 'id')
->where('jenis_komoditas_id', $request->jenis_komoditas_id),
],
'points' => [
'required',
'array',
'min:1',
],
'points.*.latitude' => [
'required',
'numeric',
],
'points.*.longitude' => [
'required',
'numeric',
],
'points.*.kondisi_tanaman' => [
'required',
Rule::in(['sehat', 'sakit', 'mati']),
],
'metode_pengumpulan' => [
'nullable',
'string',
'max:50',
],
'deskripsi' => [
'nullable',
'string',
],
], [
'points.required' => 'Minimal tambahkan satu titik tanaman pada peta.',
'points.min' => 'Minimal tambahkan satu titik tanaman pada peta.',
'points.*.latitude.required' => 'Latitude titik tanaman wajib ada.',
'points.*.longitude.required' => 'Longitude titik tanaman wajib ada.',
'points.*.kondisi_tanaman.required' => 'Kondisi tanaman wajib dipilih.',
]);
try {
DB::beginTransaction();
$lahan = Lahan::findOrFail($validated['lahan_id']);
$komoditas = Komoditas::findOrFail($validated['komoditas_id']);
$profile = Profile::findOrFail($validated['profile_id']);
if (!$lahan->uid) {
throw new \Exception('UID lahan tidak ditemukan.');
}
$profileName = str_replace(' ', '', $profile->nama);
$lahanUidPart = substr($lahan->uid, 2);
$existingCount = Tanaman::where('lahan_id', $validated['lahan_id'])
->where('komoditas_id', $validated['komoditas_id'])
->where('jenis_komoditas_id', $validated['jenis_komoditas_id'])
->where('varietas_id', $validated['varietas_id'])
->count();
$createdTanaman = [];
foreach ($validated['points'] as $index => $point) {
$number = $existingCount + $index + 1;
$createdTanaman[] = Tanaman::create([
'uid' => 'TM' . $lahanUidPart . '-' . strtoupper(Str::random(10)),
'nama' => $komoditas->nama . ' - ' . $profileName . ' - ' . $number,
'desa_kelurahan_id' => $validated['desa_kelurahan_id'],
'profile_id' => $validated['profile_id'],
'lahan_id' => $validated['lahan_id'],
'komoditas_id' => $validated['komoditas_id'],
'jenis_komoditas_id' => $validated['jenis_komoditas_id'],
'varietas_id' => $validated['varietas_id'],
'latitude' => $point['latitude'],
'longitude' => $point['longitude'],
'kondisi_tanaman' => $point['kondisi_tanaman'],
'metode_pengumpulan' => $validated['metode_pengumpulan'] ?? 'manual',
'deskripsi' => $validated['deskripsi'] ?? null,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => count($createdTanaman) . ' data tanaman berhasil disimpan.',
'redirect' => route('map-tanaman.index'),
'data' => $createdTanaman,
]);
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Gagal menyimpan tanaman bulk', [
'error' => $e->getMessage(),
'request' => $request->all(),
]);
return response()->json([
'status' => 'error',
'message' => 'Gagal menyimpan data: ' . $e->getMessage(),
], 500);
}
}
public function show(string $id)
{
$group = $this->getTanamanGroupByRepresentativeId($id);
$tanaman = $group['representative']->load([
'profile',
'lahan.desaKelurahan.kecamatan.kabupatenkota.provinsi',
'komoditas',
'jenisKomoditas',
'varietas',
]);
$points = $group['points'];
return view('produktivitas.mapTanaman.view', compact(
'tanaman',
'points'
));
}
public function edit(string $id)
{
$group = $this->getTanamanGroupByRepresentativeId($id);
$tanaman = $group['representative']->load([
'profile',
'lahan.desaKelurahan.kecamatan.kabupatenkota.provinsi',
'komoditas',
'jenisKomoditas',
'varietas',
]);
$points = $group['points'];
return view('produktivitas.mapTanaman.edit', compact(
'tanaman',
'points'
));
}
public function update(Request $request, string $id)
{
$group = $this->getTanamanGroupByRepresentativeId($id);
$representative = $group['representative'];
$validated = $request->validate([
'points' => ['required', 'array', 'min:1'],
'points.*.id' => ['nullable', 'integer', 'exists:map_tanaman,id'],
'points.*.latitude' => ['required', 'numeric'],
'points.*.longitude' => ['required', 'numeric'],
'points.*.kondisi_tanaman' => [
'required',
Rule::in(['sehat', 'sakit', 'mati']),
],
'points.*._delete' => ['nullable'],
]);
try {
DB::beginTransaction();
foreach ($validated['points'] as $point) {
if (!empty($point['_delete']) && !empty($point['id'])) {
$tanaman = Tanaman::where('id', $point['id'])
->where('profile_id', $representative->profile_id)
->where('lahan_id', $representative->lahan_id)
->where('komoditas_id', $representative->komoditas_id)
->where('jenis_komoditas_id', $representative->jenis_komoditas_id)
->where('varietas_id', $representative->varietas_id)
->first();
if ($tanaman) {
$tanaman->deleted_by = auth()->id();
$tanaman->save();
$tanaman->delete();
}
continue;
}
if (!empty($point['id'])) {
$tanaman = Tanaman::where('id', $point['id'])
->where('profile_id', $representative->profile_id)
->where('lahan_id', $representative->lahan_id)
->where('komoditas_id', $representative->komoditas_id)
->where('jenis_komoditas_id', $representative->jenis_komoditas_id)
->where('varietas_id', $representative->varietas_id)
->firstOrFail();
$tanaman->update([
'latitude' => $point['latitude'],
'longitude' => $point['longitude'],
'kondisi_tanaman' => $point['kondisi_tanaman'],
'updated_by' => auth()->id(),
]);
}
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => 'Data titik tanaman berhasil diperbarui.',
'redirect' => route('map-tanaman.index'),
]);
} catch (\Throwable $e) {
DB::rollBack();
return response()->json([
'status' => 'error',
'message' => 'Gagal memperbarui data: ' . $e->getMessage(),
], 500);
}
}
public function destroy(string $id)
{
try {
DB::beginTransaction();
$group = $this->getTanamanGroupByRepresentativeId($id);
$points = $group['points'];
foreach ($points as $point) {
$point->update([
'deleted_by' => auth()->id(),
]);
$point->delete();
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => $points->count() . ' titik tanaman berhasil dihapus.',
]);
} catch (\Throwable $e) {
DB::rollBack();
return response()->json([
'status' => 'error',
'message' => 'Gagal menghapus data: ' . $e->getMessage(),
], 500);
}
}
}
@@ -30,6 +30,25 @@ class ProfileController extends Controller
return view('master.profile.index', compact('profiles', 'provinsi'));
}
public function getByDesaKelurahan($desaId)
{
try {
$profiles = Profile::where('desa_kelurahan_id', $desaId)
->orderBy('nama', 'asc')
->get(['id', 'nama']);
return response()->json([
'status' => 'success',
'data' => $profiles,
]);
} catch (\Throwable $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
], 500);
}
}
public function create()
{
$provinsi = Provinsi::orderBy('nama', 'asc')->get();
@@ -25,6 +25,7 @@ class LahanResource extends JsonResource
'luas_lahan' => $this->luas_lahan,
'keluarga_pengelola_id' => $this->keluarga_pengelola_id,
'adat_pemilik_id' => $this->adat_pemilik_id,
'uid' => $this->uid,
];
}
}
@@ -24,6 +24,7 @@ class TanamanResource extends JsonResource
'latitude' => $this->latitude,
'longitude' => $this->longitude,
'kondisi_tanaman' => $this->kondisi_tanaman,
'uid' => $this->uid,
];
}
}
+5
View File
@@ -25,4 +25,9 @@ class JenisKomoditas extends Model
{
return $this->belongsTo(Komoditas::class, 'komoditas_id');
}
public function varietas()
{
return $this->hasMany(Varietas::class, 'jenis_komoditas_id');
}
}
+12
View File
@@ -5,6 +5,8 @@ namespace App\Models\Pertanian;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
use App\Models\Master\JenisKomoditas;
use App\Models\Master\Varietas;
class Komoditas extends Model
{
@@ -30,4 +32,14 @@ class Komoditas extends Model
$builder->whereIn('id', app('SystemProgram')->komoditas()->pluck('id'));
});
}
public function jenisKomoditas()
{
return $this->hasMany(JenisKomoditas::class, 'komoditas_id');
}
public function varietas()
{
return $this->hasMany(Varietas::class, 'komoditas_id');
}
}
+19 -2
View File
@@ -4,9 +4,11 @@ namespace App\Models\Pertanian;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Pertanian\Komoditas;
use App\Models\Pertanian\Lahan;
use App\Models\Profile;
use App\Models\Pertanian\Lahan;
use App\Models\Pertanian\Komoditas;
use App\Models\Master\JenisKomoditas;
use App\Models\Master\Varietas;
class Tanaman extends Model
{
@@ -15,13 +17,18 @@ class Tanaman extends Model
protected $table = 'map_tanaman';
protected $fillable = [
'uid',
'nama',
'jenis_komoditas_id',
'varietas_id',
'komoditas_id',
'profile_id',
'lahan_id',
'latitude',
'longitude',
'kondisi_tanaman',
'metode_pengumpulan',
'deskripsi',
'created_by',
'updated_by',
'deleted_by',
@@ -32,6 +39,16 @@ class Tanaman extends Model
return $this->belongsTo(Komoditas::class, 'komoditas_id');
}
public function jenisKomoditas()
{
return $this->belongsTo(JenisKomoditas::class, 'jenis_komoditas_id');
}
public function varietas()
{
return $this->belongsTo(Varietas::class, 'varietas_id');
}
public function profile()
{
return $this->belongsTo(Profile::class, 'profile_id');
@@ -10,6 +10,10 @@
<label class="form-label" for="add-user-fullname">Nama Lengkap</label>
<input type="text" class="form-control" id="add-user-fullname" placeholder="John Doe" name="name" required />
</div>
<div class="mb-4">
<label class="form-label" for="add-user-username">Nama akun</label>
<input type="text" class="form-control" id="add-user-username" placeholder="john" name="username" required />
</div>
<div class="mb-4">
<label class="form-label" for="add-user-email">Email</label>
<input type="email" id="add-user-email" class="form-control" placeholder="john.doe@example.com" name="email" required />
+20 -3
View File
@@ -116,7 +116,7 @@
</ul>
<ul class="menu-sub">
<li class="menu-item">
<a href="{{ route('tanaman.index') }}" class="menu-link">
<a href="{{ route('map-tanaman.index') }}" class="menu-link">
<div data-i18n="Pemantauan Tanaman">Pemantauan Tanaman</div>
</a>
</li>
@@ -251,10 +251,27 @@
</a>
</li>
<li class="menu-item">
<a href="{{ route('komoditas.index') }}" class="menu-link">
<a href="javascript:void(0);" class="menu-link menu-toggle">
<i class="menu-icon icon-base ti tabler-plant"></i>
<div data-i18n="Komoditas">Komoditas</div>
<div data-i18n="Tanaman">Tanaman</div>
</a>
<ul class="menu-sub">
<li class="menu-item">
<a href="{{ route('komoditas.index') }}" class="menu-link">
<div data-i18n="Komoditas">Komoditas</div>
</a>
</li>
<li class="menu-item">
<a href="{{ route('jenis-komoditas.index') }}" class="menu-link">
<div data-i18n="Jenis Komoditas">Jenis Komoditas</div>
</a>
</li>
<li class="menu-item">
<a href="{{ route('varietas.index') }}" class="menu-link">
<div data-i18n="Varietas">Varietas</div>
</a>
</li>
</ul>
</li>
<!-- Konfigurasi -->
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,832 @@
@extends('layout.main')
@push('style')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
#map {
height: 100%;
min-height: 620px;
width: 100%;
background-color: var(--bs-body-bg);
z-index: 1;
border-radius: 0.375rem;
}
.map-wrapper {
min-height: 620px;
position: relative;
}
.points-table-wrapper {
max-height: 320px;
overflow-y: auto;
}
.leaflet-popup-content {
color: #2f2b3d;
}
.plant-marker-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(var(--bs-success-rgb), 0.95);
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
}
.plant-marker-icon.sakit {
background: rgba(var(--bs-danger-rgb), 0.95);
}
.plant-marker-icon.mati {
background: rgba(var(--bs-dark-rgb), 0.95);
}
.plant-dot-marker {
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(var(--bs-success-rgb), 0.95);
border: 2px solid #fff;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.45);
}
.plant-dot-marker.sakit {
background: rgba(var(--bs-danger-rgb), 0.95);
}
.plant-dot-marker.mati {
background: rgba(var(--bs-dark-rgb), 0.95);
}
.detail-box {
padding: .75rem;
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
background-color: rgba(var(--bs-primary-rgb), .04);
}
.detail-label {
font-size: .75rem;
color: var(--bs-secondary-color);
margin-bottom: .25rem;
}
.detail-value {
font-weight: 500;
color: var(--bs-heading-color);
margin-bottom: 0;
}
.custom-plant-popup .leaflet-popup-content-wrapper {
border-radius: 14px;
padding: 0;
overflow: hidden;
background: var(--bs-card-bg);
color: var(--bs-body-color);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
}
.custom-plant-popup .leaflet-popup-content {
margin: 0;
min-width: 250px;
}
.custom-plant-popup .leaflet-popup-tip {
background: var(--bs-card-bg);
}
.plant-popup-card {
overflow: hidden;
}
.plant-popup-header {
padding: 10px 12px;
background: rgba(var(--bs-primary-rgb), 0.14);
border-bottom: 1px solid var(--bs-border-color);
}
.plant-popup-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--bs-heading-color);
margin: 0;
line-height: 1.3;
}
.plant-popup-subtitle {
font-size: 0.75rem;
color: var(--bs-secondary-color);
margin-top: 2px;
}
.plant-popup-body {
padding: 10px 12px 12px;
}
.plant-popup-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.75rem;
margin-bottom: 8px;
}
.plant-popup-label {
color: var(--bs-secondary-color);
}
.plant-popup-value {
color: var(--bs-heading-color);
font-weight: 500;
text-align: right;
}
.plant-popup-actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
.plant-popup-actions .btn {
flex: 1;
}
.plant-popup-help {
font-size: 0.7rem;
color: var(--bs-secondary-color);
margin-top: 8px;
line-height: 1.3;
}
</style>
@endpush
@section('content')
@php
$groupName = ($tanaman->komoditas->nama ?? 'Tanaman') . ' - ' . ($tanaman->profile->nama ?? 'Pemilik');
@endphp
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card">
<div class="card-header border-bottom mb-4 d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title mb-0">Edit Kelompok Tanaman</h5>
<small class="text-muted">{{ $groupName }}</small>
</div>
<a href="{{ route('map-tanaman.index') }}" class="btn btn-sm btn-label-secondary">
<i class="ti tabler-arrow-left me-1"></i> Kembali
</a>
</div>
<div class="card-body">
<form id="editTanamanForm" action="{{ route('map-tanaman.update', $tanaman->id) }}" method="POST">
@csrf
@method('PUT')
<div class="row">
<div class="col-lg-5 mb-4">
<h6 class="mb-3 text-primary">
<i class="ti tabler-seeding me-1"></i> Informasi Kelompok
</h6>
<div class="alert alert-info mb-3">
<i class="ti tabler-info-circle me-1"></i>
Data lahan, pemilik, komoditas, jenis, dan varietas tidak diubah dari halaman ini. Perubahan hanya untuk titik tanaman.
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="detail-box">
<div class="detail-label">Pemilik/Pengelola</div>
<div class="detail-value">{{ $tanaman->profile->nama ?? '-' }}</div>
</div>
</div>
<div class="col-md-6">
<div class="detail-box">
<div class="detail-label">Lahan</div>
<div class="detail-value">{{ $tanaman->lahan->nama ?? '-' }}</div>
</div>
</div>
<div class="col-md-6">
<div class="detail-box">
<div class="detail-label">Komoditas</div>
<div class="detail-value">{{ $tanaman->komoditas->nama ?? '-' }}</div>
</div>
</div>
<div class="col-md-6">
<div class="detail-box">
<div class="detail-label">Jenis Komoditas</div>
<div class="detail-value">{{ $tanaman->jenisKomoditas->nama ?? '-' }}</div>
</div>
</div>
<div class="col-md-12">
<div class="detail-box">
<div class="detail-label">Varietas</div>
<div class="detail-value">{{ $tanaman->varietas->nama ?? '-' }}</div>
</div>
</div>
</div>
<div class="mb-3 form-control-validation">
<label class="form-label">Deskripsi</label>
<textarea
name="deskripsi"
class="form-control"
rows="3"
placeholder="Catatan tambahan">{{ old('deskripsi', $tanaman->deskripsi ?? '') }}</textarea>
</div>
<hr class="my-4">
<h6 class="mb-3 text-primary">
<i class="ti tabler-current-location me-1"></i> Titik Koordinat Tanaman
</h6>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<label class="form-label text-success fw-bold mb-0">
Klik map untuk menambahkan titik baru
</label>
<small class="text-muted d-block">
Kondisi dan hapus titik bisa dilakukan dari tabel atau popup map.
</small>
</div>
<div class="btn-group" role="group" aria-label="Marker Mode">
<input type="radio" class="btn-check" name="marker_mode" id="marker-mode-plant" value="plant" checked>
<label class="btn btn-sm btn-outline-primary" for="marker-mode-plant">
<i class="ti tabler-plant-2 me-1"></i> Ikon
</label>
<input type="radio" class="btn-check" name="marker_mode" id="marker-mode-dot" value="dot">
<label class="btn btn-sm btn-outline-primary" for="marker-mode-dot">
<i class="ti tabler-point me-1"></i> Titik
</label>
</div>
</div>
<div class="mb-3 form-control-validation">
<input type="hidden" id="points_validation" name="points_validation">
</div>
<div class="points-table-wrapper border rounded">
<table class="table table-sm table-hover mb-0" id="points-table">
<thead>
<tr>
<th style="width: 50px;">No</th>
<th>Latitude</th>
<th>Longitude</th>
<th style="width: 150px;">Kondisi</th>
<th style="width: 60px;" class="text-center">Aksi</th>
</tr>
</thead>
<tbody>
<tr id="empty-points-row">
<td colspan="5" class="text-center text-muted py-4">
Belum ada titik tanaman. Klik area pada peta.
</td>
</tr>
</tbody>
</table>
</div>
<div id="points-hidden-wrapper"></div>
<div class="mt-4 text-end">
<a href="{{ route('map-tanaman.index') }}" class="btn btn-label-secondary me-2">
Batal
</a>
<button type="submit" class="btn btn-primary data-submit">
Simpan Perubahan
</button>
</div>
</div>
<div class="col-lg-7">
<div class="border rounded h-100 map-wrapper">
<div id="map"></div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
'use strict';
$(document).ready(function () {
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': "{{ csrf_token() }}"
}
});
const editForm = document.getElementById('editTanamanForm');
const lahanPath = {{ Illuminate\Support\Js::from($tanaman->lahan->path ?? null) }};
let plantPoints = {{ Illuminate\Support\Js::from(
$points->map(function ($point) {
$kondisi = $point->kondisi_tanaman;
$kondisiValue = is_object($kondisi) && property_exists($kondisi, 'value')
? $kondisi->value
: $kondisi;
return [
'id' => $point->id,
'nama' => $point->nama,
'latitude' => $point->latitude,
'longitude' => $point->longitude,
'kondisi_tanaman' => $kondisiValue,
'_delete' => false,
'_new' => false,
];
})->values()
) }};
const map = L.map('map').setView([-2.5489, 118.0149], 5);
L.tileLayer('https://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', {
maxZoom: 22,
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
attribution: '&copy; Google Maps Satellite'
}).addTo(map);
let currentPolygon = null;
let plantMarkers = [];
function showError(xhr, fallback = 'Terjadi kesalahan sistem.') {
let message = fallback;
if (xhr.responseJSON?.message) {
message = xhr.responseJSON.message;
}
if (xhr.responseJSON?.errors) {
message = Object.values(xhr.responseJSON.errors).flat().join('<br>');
}
Swal.fire({
icon: 'error',
title: 'Gagal!',
html: message,
customClass: {
confirmButton: 'btn btn-primary'
},
buttonsStyling: false
});
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function parsePath(path) {
if (!path) return null;
let parsed = path;
if (typeof path === 'string') {
try {
parsed = JSON.parse(path);
} catch (error) {
console.error('Path bukan JSON valid:', error);
return null;
}
}
if (parsed.type && parsed.coordinates) {
return {
type: 'geojson',
data: parsed
};
}
if (Array.isArray(parsed)) {
return {
type: 'array',
data: parsed
};
}
return null;
}
function renderLahanPolygon(path) {
const parsed = parsePath(path);
if (!parsed) return null;
let layer = null;
if (parsed.type === 'geojson') {
layer = L.geoJSON(parsed.data, {
style: {
color: '#28c76f',
fillColor: '#28c76f',
fillOpacity: 0.35,
weight: 2
}
}).addTo(map);
map.fitBounds(layer.getBounds(), { padding: [30, 30] });
}
if (parsed.type === 'array') {
const latLngs = parsed.data.map(coord => [coord[1], coord[0]]);
layer = L.polygon(latLngs, {
color: '#28c76f',
fillColor: '#28c76f',
fillOpacity: 0.35,
weight: 2
}).addTo(map);
map.fitBounds(layer.getBounds(), { padding: [30, 30] });
}
return layer;
}
function getMarkerMode() {
return $('input[name="marker_mode"]:checked').val() || 'plant';
}
function getPlantIcon(point) {
const kondisi = point.kondisi_tanaman || 'sehat';
return L.divIcon({
className: '',
html: `
<div class="plant-marker-icon ${kondisi}">
<i class="ti tabler-plant-2"></i>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -16]
});
}
function getDotIcon(point) {
const kondisi = point.kondisi_tanaman || 'sehat';
return L.divIcon({
className: '',
html: `<div class="plant-dot-marker ${kondisi}"></div>`,
iconSize: [11, 11],
iconAnchor: [5.5, 5.5],
popupAnchor: [0, -8]
});
}
function formatKondisiLabel(kondisi) {
const labels = {
sehat: 'Sehat',
sakit: 'Sakit',
mati: 'Mati'
};
return labels[kondisi] || '-';
}
function getActivePoints() {
return plantPoints.filter(point => !point._delete);
}
function buildPlantPopup(point, index, visibleIndex) {
const title = point.nama && point.nama !== 'null'
? point.nama
: `Titik Tanaman ${visibleIndex + 1}`;
return `
<div class="plant-popup-card">
<div class="plant-popup-header">
<p class="plant-popup-title">${escapeHtml(title)}</p>
<div class="plant-popup-subtitle">Titik Tanaman ${visibleIndex + 1}</div>
</div>
<div class="plant-popup-body">
<div class="plant-popup-row">
<span class="plant-popup-label">Latitude</span>
<span class="plant-popup-value">${point.latitude}</span>
</div>
<div class="plant-popup-row">
<span class="plant-popup-label">Longitude</span>
<span class="plant-popup-value">${point.longitude}</span>
</div>
<div class="mb-2">
<label class="form-label mb-1" style="font-size: .75rem;">Kondisi Tanaman</label>
<select class="form-select form-select-sm popup-point-condition" data-index="${index}">
<option value="sehat" ${point.kondisi_tanaman === 'sehat' ? 'selected' : ''}>Sehat</option>
<option value="sakit" ${point.kondisi_tanaman === 'sakit' ? 'selected' : ''}>Sakit</option>
<option value="mati" ${point.kondisi_tanaman === 'mati' ? 'selected' : ''}>Mati</option>
</select>
</div>
<div class="plant-popup-actions">
<button type="button" class="btn btn-sm btn-label-danger popup-remove-point" data-index="${index}">
<i class="ti tabler-trash me-1"></i> Hapus Titik
</button>
</div>
<div class="plant-popup-help">
Perubahan akan tersimpan setelah klik <strong>Simpan Perubahan</strong>.
</div>
</div>
</div>
`;
}
function renderPointsTable() {
const $tbody = $('#points-table tbody');
const $hiddenWrapper = $('#points-hidden-wrapper');
$tbody.empty();
$hiddenWrapper.empty();
const activePoints = getActivePoints();
if (!activePoints.length) {
$tbody.append(`
<tr id="empty-points-row">
<td colspan="5" class="text-center text-muted py-4">
Belum ada titik tanaman. Klik area pada peta.
</td>
</tr>
`);
$('#points_validation').val('');
} else {
$('#points_validation').val('filled');
}
let visibleNumber = 1;
plantPoints.forEach((point, index) => {
const prefix = `points[${index}]`;
$hiddenWrapper.append(`
<input type="hidden" name="${prefix}[id]" value="${point.id || ''}">
<input type="hidden" name="${prefix}[latitude]" value="${point.latitude}">
<input type="hidden" name="${prefix}[longitude]" value="${point.longitude}">
<input type="hidden" name="${prefix}[kondisi_tanaman]" value="${point.kondisi_tanaman}">
<input type="hidden" name="${prefix}[_delete]" value="${point._delete ? 1 : 0}">
`);
if (point._delete) return;
$tbody.append(`
<tr>
<td>${visibleNumber}</td>
<td>${point.latitude}</td>
<td>${point.longitude}</td>
<td>
<select class="form-select form-select-sm point-condition" data-index="${index}">
<option value="sehat" ${point.kondisi_tanaman === 'sehat' ? 'selected' : ''}>Sehat</option>
<option value="sakit" ${point.kondisi_tanaman === 'sakit' ? 'selected' : ''}>Sakit</option>
<option value="mati" ${point.kondisi_tanaman === 'mati' ? 'selected' : ''}>Mati</option>
</select>
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-icon btn-label-danger remove-point" data-index="${index}">
<i class="ti tabler-trash"></i>
</button>
</td>
</tr>
`);
visibleNumber++;
});
}
function refreshPlantMarkers() {
plantMarkers.forEach(marker => map.removeLayer(marker));
plantMarkers = [];
const mode = getMarkerMode();
let visibleIndex = 0;
plantPoints.forEach((point, index) => {
if (point._delete) return;
const icon = mode === 'dot'
? getDotIcon(point)
: getPlantIcon(point);
const marker = L.marker([point.latitude, point.longitude], {
icon: icon
}).addTo(map)
.bindPopup(buildPlantPopup(point, index, visibleIndex), {
className: 'custom-plant-popup',
closeButton: true,
maxWidth: 280
});
plantMarkers.push(marker);
visibleIndex++;
});
}
function refreshField(name) {
if (typeof fvEdit !== 'undefined' && name) {
fvEdit.revalidateField(name);
}
}
function refreshAll() {
renderPointsTable();
refreshPlantMarkers();
refreshField('points_validation');
}
currentPolygon = renderLahanPolygon(lahanPath);
renderPointsTable();
refreshPlantMarkers();
const activePoints = getActivePoints();
if (activePoints.length > 0) {
const bounds = L.latLngBounds(
activePoints
.filter(point => point.latitude && point.longitude)
.map(point => [point.latitude, point.longitude])
);
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [40, 40] });
}
}
map.on('click', function (e) {
const lat = e.latlng.lat.toFixed(8);
const lng = e.latlng.lng.toFixed(8);
plantPoints.push({
id: '',
nama: 'Titik Baru',
latitude: lat,
longitude: lng,
kondisi_tanaman: 'sehat',
_delete: false,
_new: true,
});
refreshAll();
});
$(document).on('change', '.point-condition', function () {
const index = $(this).data('index');
const value = $(this).val();
if (plantPoints[index]) {
plantPoints[index].kondisi_tanaman = value;
}
refreshAll();
});
$(document).on('change', '.popup-point-condition', function () {
const index = $(this).data('index');
const value = $(this).val();
if (plantPoints[index]) {
plantPoints[index].kondisi_tanaman = value;
}
map.closePopup();
refreshAll();
});
$(document).on('click', '.remove-point', function () {
const index = $(this).data('index');
if (!plantPoints[index]) return;
if (plantPoints[index].id) {
plantPoints[index]._delete = true;
} else {
plantPoints.splice(index, 1);
}
refreshAll();
});
$(document).on('click', '.popup-remove-point', function () {
const index = $(this).data('index');
if (!plantPoints[index]) return;
Swal.fire({
title: 'Hapus titik ini?',
text: 'Titik tanaman akan dihapus setelah kamu klik Simpan Perubahan.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Ya, hapus',
cancelButtonText: 'Batal',
customClass: {
confirmButton: 'btn btn-primary me-3',
cancelButton: 'btn btn-label-secondary'
},
buttonsStyling: false
}).then(function (result) {
if (!result.isConfirmed) return;
if (plantPoints[index].id) {
plantPoints[index]._delete = true;
} else {
plantPoints.splice(index, 1);
}
map.closePopup();
refreshAll();
});
});
$(document).on('change', 'input[name="marker_mode"]', function () {
refreshPlantMarkers();
});
let fvEdit = FormValidation.formValidation(editForm, {
fields: {
points_validation: {
validators: {
callback: {
message: 'Minimal harus ada satu titik tanaman aktif.',
callback: function () {
return getActivePoints().length > 0;
}
}
}
},
},
plugins: {
trigger: new FormValidation.plugins.Trigger(),
bootstrap5: new FormValidation.plugins.Bootstrap5({
eleValidClass: '',
rowSelector: '.form-control-validation'
}),
submitButton: new FormValidation.plugins.SubmitButton(),
}
}).on('core.form.valid', function () {
const formData = $(editForm).serialize();
const $button = $(editForm).find('button[type="submit"]');
$button.prop('disabled', true)
.html('<span class="spinner-border spinner-border-sm me-1"></span>Menyimpan...');
$.ajax({
url: editForm.action,
type: 'POST',
data: formData,
success: function (res) {
Swal.fire({
icon: 'success',
title: 'Berhasil!',
text: res.message || 'Data titik tanaman berhasil diperbarui.',
timer: 1500,
showConfirmButton: false
}).then(() => {
window.location.href = res.redirect || "{{ route('map-tanaman.index') }}";
});
},
error: function (xhr) {
showError(xhr, 'Gagal memperbarui data tanaman.');
},
complete: function () {
$button.prop('disabled', false).html('Simpan Perubahan');
}
});
});
setTimeout(() => map.invalidateSize(), 300);
});
</script>
@endpush
@@ -0,0 +1,230 @@
@extends('layout.main')
@section('content')
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card">
<div class="card-body border-bottom">
<form method="GET" action="{{ route('tanaman.index') }}" class="d-flex justify-content-between align-items-center row gap-3 gap-md-0">
<div class="col-md-auto">
<div class="d-flex align-items-center">
<label class="me-2 text-nowrap">Show</label>
<select name="per_page" class="form-select form-select-sm w-auto" onchange="this.form.submit()">
@foreach([10, 25, 50, 100] as $limit)
<option value="{{ $limit }}" {{ request('per_page', 10) == $limit ? 'selected' : '' }}>
{{ $limit }}
</option>
@endforeach
</select>
<label class="ms-2 text-nowrap">entries</label>
</div>
</div>
<div class="col-md-auto d-flex gap-2">
<div class="input-group input-group-sm w-auto">
<input type="text" name="search" class="form-control" placeholder="Cari tanaman..." value="{{ request('search') }}">
@if(request('search'))
<a href="{{ route('tanaman.index', ['per_page' => request('per_page', 10)]) }}" class="btn btn-outline-secondary">
<i class="ti tabler-x"></i>
</a>
@endif
<button type="submit" class="btn btn-outline-primary">
<i class="ti tabler-search"></i>
</button>
</div>
<a href="{{ route('map-tanaman.create') }}" class="btn btn-sm btn-primary">
<i class="ti tabler-plus me-1"></i> Tambah
</a>
</div>
</form>
</div>
<div class="table-responsive text-nowrap">
<table class="table table-hover">
<thead class="border-top">
<tr>
<th>No</th>
<th>Kelompok Tanaman</th>
<th>Pemilik</th>
<th>Lahan</th>
<th>Wilayah</th>
<th>Varietas</th>
<th>Jumlah Titik</th>
<th>Kondisi</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
@forelse($tanaman as $index => $item)
@php
$states = ['success', 'danger', 'warning', 'info', 'primary', 'secondary'];
$state = $states[$item->id % count($states)];
$groupName = ($item->komoditas->nama ?? 'Tanaman') . ' - ' . ($item->profile->nama ?? 'Pemilik');
@endphp
<tr>
<td>{{ $tanaman->firstItem() + $index }}</td>
<td>
<div class="d-flex align-items-center">
<div class="avatar-wrapper me-3">
<div class="avatar avatar-sm">
<span class="avatar-initial rounded-circle bg-label-{{ $state }}">
{{ strtoupper(substr($item->komoditas->nama ?? 'TN', 0, 2)) }}
</span>
</div>
</div>
<div class="d-flex flex-column">
<span class="fw-medium text-heading">{{ $groupName }}</span>
<small class="text-muted">
{{ $item->jenisKomoditas->nama ?? '-' }}
</small>
</div>
</div>
</td>
<td>{{ $item->profile->nama ?? '-' }}</td>
<td>
<div class="d-flex flex-column">
<span>{{ $item->lahan->nama ?? '-' }}</span>
<small class="text-muted">{{ $item->lahan->uid ?? '-' }}</small>
</div>
</td>
<td>
<small>
{{ $item->lahan->desaKelurahan->nama ?? '-' }}<br>
<span class="text-muted">
{{ $item->lahan->desaKelurahan->kecamatan->nama ?? '-' }}
</span>
</small>
</td>
<td>{{ $item->varietas->nama ?? '-' }}</td>
<td>
<span class="badge bg-label-primary">
{{ $item->total_tanaman }} titik
</span>
</td>
<td>
<div class="d-flex flex-column gap-1">
<span class="badge bg-label-success">
Sehat: {{ $item->total_sehat ?? 0 }}
</span>
<span class="badge bg-label-danger">
Sakit: {{ $item->total_sakit ?? 0 }}
</span>
<span class="badge bg-label-dark">
Mati: {{ $item->total_mati ?? 0 }}
</span>
</div>
</td>
<td>
<div class="d-flex justify-content-center align-items-center">
<a href="{{ route('map-tanaman.show', $item->id) }}"
class="btn btn-icon btn-text-secondary rounded-pill"
title="Lihat">
<i class="ti tabler-eye ti-md"></i>
</a>
<a href="{{ route('map-tanaman.edit', $item->id) }}"
class="btn btn-icon btn-text-secondary rounded-pill"
title="Edit">
<i class="ti tabler-edit ti-md"></i>
</a>
<button
type="button"
class="btn btn-icon btn-text-secondary rounded-pill delete-group"
data-id="{{ $item->id }}"
data-nama="{{ $groupName }}"
title="Hapus">
<i class="ti tabler-trash ti-md"></i>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="9" class="text-center py-4">
Tidak ada data ditemukan.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($tanaman->hasPages())
<div class="card-footer d-flex justify-content-center justify-content-md-end border-top">
{{ $tanaman->appends(request()->query())->links('pagination::bootstrap-5') }}
</div>
@endif
</div>
</div>
@endsection
@push('scripts')
<script>
'use strict';
$(function () {
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': "{{ csrf_token() }}"
}
});
$(document).on('click', '.delete-group', function () {
const id = $(this).data('id');
const nama = $(this).data('nama');
Swal.fire({
title: 'Apakah Anda yakin?',
text: `Semua titik tanaman pada kelompok "${nama}" akan dihapus.`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Ya, Hapus!',
cancelButtonText: 'Batal',
customClass: {
confirmButton: 'btn btn-primary me-3',
cancelButton: 'btn btn-label-secondary'
},
buttonsStyling: false
}).then(function (result) {
if (!result.isConfirmed) return;
$.ajax({
url: `/map-tanaman/${id}`,
type: 'DELETE',
success: function (res) {
Swal.fire({
icon: 'success',
title: 'Terhapus!',
text: res.message || 'Data berhasil dihapus.',
showConfirmButton: false,
timer: 1500
}).then(() => window.location.reload());
},
error: function (xhr) {
Swal.fire({
icon: 'error',
title: 'Gagal!',
text: xhr.responseJSON?.message || 'Gagal menghapus data.'
});
}
});
});
});
});
</script>
@endpush
@@ -0,0 +1,698 @@
@extends('layout.main')
@push('style')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
#map {
height: 560px;
width: 100%;
background-color: var(--bs-body-bg);
z-index: 1;
border-radius: 0.375rem;
}
.detail-label {
font-size: 0.8125rem;
color: var(--bs-secondary-color);
margin-bottom: 0.25rem;
}
.detail-value {
font-weight: 500;
color: var(--bs-heading-color);
margin-bottom: 1rem;
word-break: break-word;
}
.detail-section {
padding-bottom: .75rem;
margin-bottom: .75rem;
border-bottom: 1px solid var(--bs-border-color);
}
.detail-section:last-child {
border-bottom: 0;
margin-bottom: 0;
padding-bottom: 0;
}
.map-note {
background-color: rgba(var(--bs-info-rgb), 0.16);
color: var(--bs-info);
border: 1px solid rgba(var(--bs-info-rgb), 0.25);
}
.points-table-wrapper {
max-height: 280px;
overflow-y: auto;
}
.leaflet-popup-content {
color: #2f2b3d;
}
.plant-marker-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(var(--bs-success-rgb), 0.95);
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
}
.plant-marker-icon.sakit {
background: rgba(var(--bs-danger-rgb), 0.95);
}
.plant-marker-icon.mati {
background: rgba(var(--bs-dark-rgb), 0.95);
}
.plant-dot-marker {
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(var(--bs-success-rgb), 0.95);
border: 2px solid #fff;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.45);
}
.plant-dot-marker.sakit {
background: rgba(var(--bs-danger-rgb), 0.95);
}
.plant-dot-marker.mati {
background: rgba(var(--bs-dark-rgb), 0.95);
}
.custom-plant-popup .leaflet-popup-content-wrapper {
border-radius: 14px;
padding: 0;
overflow: hidden;
background: var(--bs-card-bg);
color: var(--bs-body-color);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
}
.custom-plant-popup .leaflet-popup-content {
margin: 0;
min-width: 220px;
}
.custom-plant-popup .leaflet-popup-tip {
background: var(--bs-card-bg);
}
.plant-popup-card {
overflow: hidden;
}
.plant-popup-header {
padding: 10px 12px;
background: rgba(var(--bs-primary-rgb), 0.14);
border-bottom: 1px solid var(--bs-border-color);
}
.plant-popup-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--bs-heading-color);
margin: 0;
line-height: 1.3;
}
.plant-popup-subtitle {
font-size: 0.75rem;
color: var(--bs-secondary-color);
margin-top: 2px;
}
.plant-popup-body {
padding: 10px 12px 12px;
}
.plant-popup-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.75rem;
margin-bottom: 6px;
}
.plant-popup-row:last-child {
margin-bottom: 0;
}
.plant-popup-label {
color: var(--bs-secondary-color);
}
.plant-popup-value {
color: var(--bs-heading-color);
font-weight: 500;
text-align: right;
}
.plant-popup-badge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
}
.plant-popup-badge.sehat {
background: rgba(var(--bs-success-rgb), 0.16);
color: var(--bs-success);
}
.plant-popup-badge.sakit {
background: rgba(var(--bs-danger-rgb), 0.16);
color: var(--bs-danger);
}
.plant-popup-badge.mati {
background: rgba(var(--bs-dark-rgb), 0.16);
color: var(--bs-dark);
}
</style>
@endpush
@section('content')
@php
$totalTanaman = $points->count();
$totalSehat = $points->filter(function ($point) {
$kondisi = $point->kondisi_tanaman;
$value = is_object($kondisi) && property_exists($kondisi, 'value') ? $kondisi->value : $kondisi;
return $value === 'sehat';
})->count();
$totalSakit = $points->filter(function ($point) {
$kondisi = $point->kondisi_tanaman;
$value = is_object($kondisi) && property_exists($kondisi, 'value') ? $kondisi->value : $kondisi;
return $value === 'sakit';
})->count();
$totalMati = $points->filter(function ($point) {
$kondisi = $point->kondisi_tanaman;
$value = is_object($kondisi) && property_exists($kondisi, 'value') ? $kondisi->value : $kondisi;
return $value === 'mati';
})->count();
$groupName = ($tanaman->komoditas->nama ?? 'Tanaman') . ' - ' . ($tanaman->profile->nama ?? 'Pemilik');
@endphp
<div class="container-xxl flex-grow-1 container-p-y">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="mb-1">Detail Kelompok Tanaman</h4>
<small class="text-muted">{{ $groupName }}</small>
</div>
<div class="d-flex gap-2">
<a href="{{ route('map-tanaman.index') }}" class="btn btn-label-secondary">
<i class="ti tabler-arrow-left me-1"></i> Kembali
</a>
<a href="{{ route('map-tanaman.edit', $tanaman->id) }}" class="btn btn-primary">
<i class="ti tabler-edit me-1"></i> Edit
</a>
</div>
</div>
<div class="row">
{{-- DETAIL DATA --}}
<div class="col-lg-5 mb-4">
<div class="card mb-4">
<div class="card-header border-bottom">
<h5 class="card-title mb-0">
<i class="ti tabler-seeding me-1"></i> Ringkasan Kelompok
</h5>
</div>
<div class="card-body pt-4">
<div class="detail-section">
<div class="detail-label">Kelompok Tanaman</div>
<div class="detail-value">{{ $groupName }}</div>
</div>
<div class="detail-section">
<div class="detail-label">Jumlah Titik Tanaman</div>
<div class="detail-value">
<span class="badge bg-label-primary">{{ $totalTanaman }} titik</span>
</div>
</div>
<div class="detail-section">
<div class="detail-label">Ringkasan Kondisi</div>
<div class="d-flex flex-wrap gap-2">
<span class="badge bg-label-success">Sehat: {{ $totalSehat }}</span>
<span class="badge bg-label-danger">Sakit: {{ $totalSakit }}</span>
<span class="badge bg-label-dark">Mati: {{ $totalMati }}</span>
</div>
</div>
<div class="detail-section">
<div class="detail-label">Deskripsi</div>
<div class="detail-value">{{ $tanaman->deskripsi ?? '-' }}</div>
</div>
<div class="detail-section">
<div class="detail-label">Metode Pengumpulan</div>
<div class="detail-value">{{ $tanaman->metode_pengumpulan ?? '-' }}</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header border-bottom">
<h5 class="card-title mb-0">
<i class="ti tabler-user-pin me-1"></i> Pemilik & Lahan
</h5>
</div>
<div class="card-body pt-4">
<div class="detail-section">
<div class="detail-label">Pemilik/Pengelola</div>
<div class="detail-value">{{ $tanaman->profile->nama ?? '-' }}</div>
</div>
<div class="detail-section">
<div class="detail-label">Lahan</div>
<div class="detail-value">{{ $tanaman->lahan->nama ?? '-' }}</div>
</div>
<div class="detail-section">
<div class="detail-label">UID Lahan</div>
<div class="detail-value">{{ $tanaman->lahan->uid ?? '-' }}</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header border-bottom">
<h5 class="card-title mb-0">
<i class="ti tabler-map-pin me-1"></i> Wilayah
</h5>
</div>
<div class="card-body pt-4">
<div class="detail-section">
<div class="detail-label">Provinsi</div>
<div class="detail-value">
{{ $tanaman->lahan->desaKelurahan->kecamatan->kabupatenkota->provinsi->nama ?? '-' }}
</div>
</div>
<div class="detail-section">
<div class="detail-label">Kabupaten/Kota</div>
<div class="detail-value">
{{ $tanaman->lahan->desaKelurahan->kecamatan->kabupatenkota->nama ?? '-' }}
</div>
</div>
<div class="detail-section">
<div class="detail-label">Kecamatan</div>
<div class="detail-value">
{{ $tanaman->lahan->desaKelurahan->kecamatan->nama ?? '-' }}
</div>
</div>
<div class="detail-section">
<div class="detail-label">Desa/Kelurahan</div>
<div class="detail-value">
{{ $tanaman->lahan->desaKelurahan->nama ?? '-' }}
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header border-bottom">
<h5 class="card-title mb-0">
<i class="ti tabler-leaf me-1"></i> Komoditas
</h5>
</div>
<div class="card-body pt-4">
<div class="detail-section">
<div class="detail-label">Komoditas</div>
<div class="detail-value">{{ $tanaman->komoditas->nama ?? '-' }}</div>
</div>
<div class="detail-section">
<div class="detail-label">Jenis Komoditas</div>
<div class="detail-value">{{ $tanaman->jenisKomoditas->nama ?? '-' }}</div>
</div>
<div class="detail-section">
<div class="detail-label">Varietas</div>
<div class="detail-value">{{ $tanaman->varietas->nama ?? '-' }}</div>
</div>
</div>
</div>
</div>
{{-- MAP --}}
<div class="col-lg-7 mb-4">
<div class="card mb-4">
<div class="card-header border-bottom d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="ti tabler-map-pin me-1"></i> Lokasi Semua Titik Tanaman
</h5>
<div class="btn-group" role="group" aria-label="Marker Mode">
<input type="radio" class="btn-check" name="marker_mode" id="marker-mode-plant" value="plant" checked>
<label class="btn btn-sm btn-outline-primary" for="marker-mode-plant">
<i class="ti tabler-plant-2 me-1"></i> Ikon
</label>
<input type="radio" class="btn-check" name="marker_mode" id="marker-mode-dot" value="dot">
<label class="btn btn-sm btn-outline-primary" for="marker-mode-dot">
<i class="ti tabler-point me-1"></i> Titik
</label>
</div>
</div>
<div class="card-body pt-4">
<div id="map"></div>
<div class="alert map-note mt-3 mb-0">
<i class="ti tabler-info-circle me-1"></i>
Area hijau menunjukkan polygon lahan. Marker menunjukkan seluruh titik tanaman dalam kelompok ini.
</div>
</div>
</div>
<div class="card">
<div class="card-header border-bottom">
<h5 class="card-title mb-0">
<i class="ti tabler-list-details me-1"></i> Daftar Titik Tanaman
</h5>
</div>
<div class="card-body pt-4">
<div class="points-table-wrapper border rounded">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th style="width: 50px;">No</th>
<th>Nama</th>
<th>Latitude</th>
<th>Longitude</th>
<th>Kondisi</th>
</tr>
</thead>
<tbody>
@forelse($points as $index => $point)
@php
$kondisi = $point->kondisi_tanaman;
$kondisiValue = is_object($kondisi) && property_exists($kondisi, 'value')
? $kondisi->value
: $kondisi;
$conditionClass = match($kondisiValue) {
'sehat' => 'success',
'sakit' => 'danger',
'mati' => 'dark',
default => 'secondary',
};
@endphp
<tr>
<td>{{ $index + 1 }}</td>
<td>
<div class="fw-medium">{{ $point->nama ?? '-' }}</div>
<small class="text-muted">{{ $point->uid ?? '-' }}</small>
</td>
<td>{{ $point->latitude ?? '-' }}</td>
<td>{{ $point->longitude ?? '-' }}</td>
<td>
<span class="badge bg-label-{{ $conditionClass }}">
{{ $kondisiValue ? ucfirst(str_replace('_', ' ', $kondisiValue)) : '-' }}
</span>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center text-muted py-4">
Belum ada titik tanaman.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
'use strict';
$(document).ready(function () {
const lahanPath = {{ Illuminate\Support\Js::from($tanaman->lahan->path ?? null) }};
const points = {{ Illuminate\Support\Js::from(
$points->map(function ($point) {
$kondisi = $point->kondisi_tanaman;
$kondisiValue = is_object($kondisi) && property_exists($kondisi, 'value')
? $kondisi->value
: $kondisi;
return [
'id' => $point->id,
'uid' => $point->uid,
'nama' => $point->nama,
'latitude' => $point->latitude,
'longitude' => $point->longitude,
'kondisi_tanaman' => $kondisiValue,
];
})->values()
) }};
const map = L.map('map').setView([-2.5489, 118.0149], 5);
L.tileLayer('https://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', {
maxZoom: 22,
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
attribution: '&copy; Google Maps Satellite'
}).addTo(map);
let pointMarkers = [];
function parsePath(path) {
if (!path) return null;
let parsed = path;
if (typeof path === 'string') {
try {
parsed = JSON.parse(path);
} catch (error) {
console.error('Path bukan JSON valid:', error);
return null;
}
}
if (parsed.type && parsed.coordinates) {
return {
type: 'geojson',
data: parsed
};
}
if (Array.isArray(parsed)) {
return {
type: 'array',
data: parsed
};
}
return null;
}
function renderLahanPolygon(path) {
const parsed = parsePath(path);
if (!parsed) {
console.warn('Format koordinat lahan tidak valid atau kosong.');
return null;
}
let layer = null;
if (parsed.type === 'geojson') {
layer = L.geoJSON(parsed.data, {
style: {
color: '#28c76f',
fillColor: '#28c76f',
fillOpacity: 0.35,
weight: 2
}
}).addTo(map);
map.fitBounds(layer.getBounds(), { padding: [30, 30] });
}
if (parsed.type === 'array') {
const latLngs = parsed.data.map(coord => [coord[1], coord[0]]);
layer = L.polygon(latLngs, {
color: '#28c76f',
fillColor: '#28c76f',
fillOpacity: 0.35,
weight: 2
}).addTo(map);
map.fitBounds(layer.getBounds(), { padding: [30, 30] });
}
return layer;
}
function getMarkerMode() {
return $('input[name="marker_mode"]:checked').val() || 'plant';
}
function getPlantIcon(point) {
const kondisi = point.kondisi_tanaman || 'sehat';
return L.divIcon({
className: '',
html: `
<div class="plant-marker-icon ${kondisi}">
<i class="ti tabler-plant-2"></i>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -16]
});
}
function getDotIcon(point) {
const kondisi = point.kondisi_tanaman || 'sehat';
return L.divIcon({
className: '',
html: `<div class="plant-dot-marker ${kondisi}"></div>`,
iconSize: [11, 11],
iconAnchor: [5.5, 5.5],
popupAnchor: [0, -8]
});
}
function formatKondisiLabel(kondisi) {
const labels = {
sehat: 'Sehat',
sakit: 'Sakit',
mati: 'Mati'
};
return labels[kondisi] || '-';
}
function renderPointMarkers() {
pointMarkers.forEach(marker => map.removeLayer(marker));
pointMarkers = [];
const mode = getMarkerMode();
points.forEach((point, index) => {
if (!point.latitude || !point.longitude) return;
const icon = mode === 'dot'
? getDotIcon(point)
: getPlantIcon(point);
const popupContent = `
<div class="plant-popup-card">
<div class="plant-popup-header">
<p class="plant-popup-title">
${point.nama || 'Titik Tanaman ' + (index + 1)}
</p>
<div class="plant-popup-subtitle">
Titik Tanaman ${index + 1}
</div>
</div>
<div class="plant-popup-body">
<div class="plant-popup-row">
<span class="plant-popup-label">Latitude</span>
<span class="plant-popup-value">${point.latitude}</span>
</div>
<div class="plant-popup-row">
<span class="plant-popup-label">Longitude</span>
<span class="plant-popup-value">${point.longitude}</span>
</div>
<div class="plant-popup-row">
<span class="plant-popup-label">Kondisi</span>
<span class="plant-popup-value">
<span class="plant-popup-badge ${point.kondisi_tanaman || 'sehat'}">
${formatKondisiLabel(point.kondisi_tanaman)}
</span>
</span>
</div>
</div>
</div>
`;
const marker = L.marker([point.latitude, point.longitude], {
icon: icon
}).addTo(map)
.bindPopup(popupContent, {
className: 'custom-plant-popup',
closeButton: true,
maxWidth: 260
});
pointMarkers.push(marker);
});
}
const lahanLayer = renderLahanPolygon(lahanPath);
renderPointMarkers();
if (points.length > 0) {
const bounds = L.latLngBounds(
points
.filter(point => point.latitude && point.longitude)
.map(point => [point.latitude, point.longitude])
);
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [40, 40] });
}
} else if (lahanLayer) {
map.fitBounds(lahanLayer.getBounds(), { padding: [30, 30] });
}
$(document).on('change', 'input[name="marker_mode"]', function () {
renderPointMarkers();
});
setTimeout(function () {
map.invalidateSize();
}, 300);
});
</script>
@endpush
+5
View File
@@ -79,8 +79,10 @@ Route::middleware('auth')
Route::get('/profile/edit/{id}', [App\Http\Controllers\Web\Produktivitas\ProfileController::class, 'edit'])->name('profile.edit');
Route::put('/profile/{id}', [App\Http\Controllers\Web\Produktivitas\ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile/{id}', [App\Http\Controllers\Web\Produktivitas\ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('/profile-by-desa-kelurahan/{desaId}', [App\Http\Controllers\Web\Produktivitas\ProfileController::class, 'getByDesaKelurahan'])->name('profile.by-desa-kelurahan');
Route::resource('lahan', App\Http\Controllers\Web\Produktivitas\LahanController::class);
Route::get('/lahan-by-profile/{profileId}', [App\Http\Controllers\Web\Produktivitas\LahanController::class, 'getByProfile'])->name('lahan.by-profile');
Route::get('/komoditas', [App\Http\Controllers\Web\Master\KomoditasController::class, 'index'])->name('komoditas.index');
Route::post('/komoditas', [App\Http\Controllers\Web\Master\KomoditasController::class, 'store'])->name('komoditas.store');
@@ -91,6 +93,9 @@ Route::middleware('auth')
Route::resource('jenis-komoditas', App\Http\Controllers\Web\Master\JenisKomoditasController::class);
Route::get('/jenis-komoditas-by-komoditas/{komoditas_id}', [App\Http\Controllers\Web\Master\JenisKomoditasController::class, 'getByKomoditas'])->name('jenis-komoditas.by-komoditas');
Route::resource('varietas', App\Http\Controllers\Web\Master\VarietasController::class);
Route::get('/varietas-by-jenis-komoditas/{jenis_komoditas_id}', [App\Http\Controllers\Web\Master\VarietasController::class, 'getByJenisKomoditas'])->name('varietas.by-jenis-komoditas');
Route::resource('map-tanaman', App\Http\Controllers\Web\Produktivitas\MapTanamanController::class);
Route::get('/keluarga', [App\Http\Controllers\Web\Master\KeluargaController::class, 'index'])->name('keluarga.index');
Route::get('/keluarga/add', [App\Http\Controllers\Web\Master\KeluargaController::class, 'create'])->name('keluarga.create');