major file change
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<link rel="icon" type="image/svg+xml" href="https://base44.com/logo_v2.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo_001.png" />
|
||||||
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" /> -->
|
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" /> -->
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<title>Agro Monev</title>
|
<title>Agro Monev</title>
|
||||||
|
|||||||
Regular → Executable
+26
@@ -13,6 +13,7 @@
|
|||||||
"@capacitor/android": "^8.1.0",
|
"@capacitor/android": "^8.1.0",
|
||||||
"@capacitor/cli": "^8.1.0",
|
"@capacitor/cli": "^8.1.0",
|
||||||
"@capacitor/core": "^8.1.0",
|
"@capacitor/core": "^8.1.0",
|
||||||
|
"@capacitor/geolocation": "^8.1.0",
|
||||||
"@capacitor/ios": "^8.1.0",
|
"@capacitor/ios": "^8.1.0",
|
||||||
"@emotion/is-prop-valid": "^1.4.0",
|
"@emotion/is-prop-valid": "^1.4.0",
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dexie": "^4.3.0",
|
"dexie": "^4.3.0",
|
||||||
"dexie-react-hooks": "^4.2.0",
|
"dexie-react-hooks": "^4.2.0",
|
||||||
@@ -493,6 +495,18 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/geolocation": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-dHeGuVlT3ELxxn8WaoRsGjfx8tmziaC0ZuaKhUBCoKFjOue4fAnmLczCMQsYW7OykgiyBh0pirNPwWZ0/LXB7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/synapse": "^1.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/ios": {
|
"node_modules/@capacitor/ios": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.1.0.tgz",
|
||||||
@@ -502,6 +516,12 @@
|
|||||||
"@capacitor/core": "^8.1.0"
|
"@capacitor/core": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/synapse": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/@emotion/is-prop-valid": {
|
"node_modules/@emotion/is-prop-valid": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||||
@@ -4607,6 +4627,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crypto-js": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/css-box-model": {
|
"node_modules/css-box-model": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||||
|
|||||||
Regular → Executable
+1
@@ -18,6 +18,7 @@
|
|||||||
"@capacitor/android": "^8.1.0",
|
"@capacitor/android": "^8.1.0",
|
||||||
"@capacitor/cli": "^8.1.0",
|
"@capacitor/cli": "^8.1.0",
|
||||||
"@capacitor/core": "^8.1.0",
|
"@capacitor/core": "^8.1.0",
|
||||||
|
"@capacitor/geolocation": "^8.1.0",
|
||||||
"@capacitor/ios": "^8.1.0",
|
"@capacitor/ios": "^8.1.0",
|
||||||
"@emotion/is-prop-valid": "^1.4.0",
|
"@emotion/is-prop-valid": "^1.4.0",
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
|
|||||||
Regular → Executable
+9
@@ -11,6 +11,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../pertamina-point"
|
"path": "../pertamina-point"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../dev/wayfinder-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../own-cloud"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../skhp"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {}
|
"settings": {}
|
||||||
|
|||||||
Regular → Executable
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+17
-16
@@ -19,7 +19,7 @@ function SyncIndicator() {
|
|||||||
|
|
||||||
const updateStatus = async () => {
|
const updateStatus = async () => {
|
||||||
const isConnected = await checkInternet();
|
const isConnected = await checkInternet();
|
||||||
const count = await OfflineService.getPendingCount(); // Ambil dari SQLite
|
const count = await OfflineService.getPendingCount();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setOnline(isConnected);
|
setOnline(isConnected);
|
||||||
@@ -94,7 +94,6 @@ async function checkInternet() {
|
|||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setTimeout(() => controller.abort(), 3000);
|
setTimeout(() => controller.abort(), 3000);
|
||||||
// Gunakan favicon google sebagai probe yang ringan
|
|
||||||
await fetch("https://www.google.com/favicon.ico", {
|
await fetch("https://www.google.com/favicon.ico", {
|
||||||
method: "HEAD",
|
method: "HEAD",
|
||||||
mode: "no-cors",
|
mode: "no-cors",
|
||||||
@@ -109,16 +108,16 @@ async function checkInternet() {
|
|||||||
|
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Dashboard", page: "Dashboard", icon: LayoutDashboard , roles: ['super_admin']},
|
{ name: "Dashboard", page: "Dashboard", icon: LayoutDashboard , roles: ['super_admin','fasilitator']},
|
||||||
{ name: "Petani", page: "Farmers", icon: Users, roles: ['super_admin'] },
|
{ name: "Petani", page: "Farmers", icon: Users, roles: ['super_admin','fasilitator'] },
|
||||||
{ name: "Lahan", page: "Lands", icon: Map, roles: ['super_admin'] },
|
{ name: "Lahan", page: "Lands", icon: Map, roles: ['super_admin','fasilitator'] },
|
||||||
{ name: "Tanaman", page: "Plants", icon: TreePine, roles: ['super_admin'] },
|
{ name: "Tanaman", page: "Plants", icon: TreePine, roles: ['super_admin','fasilitator'] },
|
||||||
{ name: "Produktivitas", page: "ProductivityMonitoring", icon: LayoutDashboard, roles: ['super_admin', 'petani'] },
|
{ name: "Produktivitas", page: "ProductivityMonitoring", icon: LayoutDashboard, roles: ['super_admin', 'petani','fasilitator'] },
|
||||||
{ name: "Validator", page: "Validators", icon: Shield, roles: ['super_admin'] },
|
{ name: "Facilitator", page: "Validators", icon: Shield, roles: ['super_admin','fasilitator'] },
|
||||||
{ name: "Offtaker", page: "Offtakers", icon: Users, roles: ['super_admin'] },
|
{ name: "Offtaker", page: "Offtakers", icon: Users, roles: ['super_admin','fasilitator'] },
|
||||||
{ name: "Portal Petani", page: "FarmerPortal", icon: Users, roles: ['super_admin', 'petani'] },
|
{ name: "Portal Petani", page: "FarmerPortal", icon: Users, roles: ['super_admin', 'petani','fasilitator'] },
|
||||||
{ name: "Portal Validator", page: "ValidatorPortal", icon: Shield, roles: ['super_admin'] },
|
{ name: "Portal Validator", page: "ValidatorPortal", icon: Shield, roles: ['super_admin','fasilitator'] },
|
||||||
{ name: "Portal Offtaker", page: "OfftakerPortal", icon: Users, roles: ['super_admin'] },
|
{ name: "Portal Offtaker", page: "OfftakerPortal", icon: Users, roles: ['super_admin','fasilitator'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Layout({ children, currentPageName }) {
|
export default function Layout({ children, currentPageName }) {
|
||||||
@@ -148,8 +147,9 @@ export default function Layout({ children, currentPageName }) {
|
|||||||
<Menu className="w-6 h-6" />
|
<Menu className="w-6 h-6" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br flex items-center justify-center">
|
||||||
<TreePine className="w-5 h-5 text-white" />
|
{/* <TreePine className="w-5 h-5 text-white" /> */}
|
||||||
|
<img src="/logo1.png" alt="" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-slate-900">AgroMonev</span>
|
<span className="font-bold text-slate-900">AgroMonev</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,8 +173,9 @@ export default function Layout({ children, currentPageName }) {
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/25">
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center shadow-lg shadow-emerald-500/25">
|
||||||
<TreePine className="w-6 h-6 text-white" />
|
{/* <TreePine className="w-6 h-6 text-white" /> */}
|
||||||
|
<img src="/logo1.png" alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-bold text-slate-900">AgroMonev</h1>
|
<h1 className="font-bold text-slate-900">AgroMonev</h1>
|
||||||
|
|||||||
Regular → Executable
+1
-1
@@ -60,7 +60,7 @@ const navigation = [
|
|||||||
{ name: "Lahan", page: "Lands", icon: Map },
|
{ name: "Lahan", page: "Lands", icon: Map },
|
||||||
{ name: "Tanaman", page: "Plants", icon: TreePine },
|
{ name: "Tanaman", page: "Plants", icon: TreePine },
|
||||||
{ name: "Produktivitas", page: "ProductivityMonitoring", icon: LayoutDashboard },
|
{ name: "Produktivitas", page: "ProductivityMonitoring", icon: LayoutDashboard },
|
||||||
{ name: "Validator", page: "Validators", icon: Shield },
|
{ name: "Facilitator", page: "Validators", icon: Shield },
|
||||||
{ name: "Offtaker", page: "Offtakers", icon: Users },
|
{ name: "Offtaker", page: "Offtakers", icon: Users },
|
||||||
{ name: "Portal Petani", page: "FarmerPortal", icon: Users },
|
{ name: "Portal Petani", page: "FarmerPortal", icon: Users },
|
||||||
{ name: "Portal Validator", page: "ValidatorPortal", icon: Shield },
|
{ name: "Portal Validator", page: "ValidatorPortal", icon: Shield },
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+2
-3
@@ -17,12 +17,11 @@ export function useSyncManager() {
|
|||||||
const hasPending = await OfflineService.hasPendingData();
|
const hasPending = await OfflineService.hasPendingData();
|
||||||
|
|
||||||
if (hasPending) {
|
if (hasPending) {
|
||||||
// console.log("📤 Memulai Push Sinkronisasi...");
|
|
||||||
await OfflineService.syncAllPending();
|
await OfflineService.syncAllPending();
|
||||||
toast.success("Sinkronisasi data berhasil");
|
toast.success("Sinkronisasi data berhasil");
|
||||||
} else {
|
} else {
|
||||||
// console.log("🔄 Data lokal bersih. Melakukan Pull dari server...");
|
// await OfflineService.downloadFromServer();
|
||||||
await OfflineService.downloadFromServer();
|
await OfflineService.syncDataFromServer();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync process failed:', error);
|
console.error('Sync process failed:', error);
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
+91
-25
@@ -14,7 +14,7 @@ const agroApi = axios.create({
|
|||||||
export const OfflineService = {
|
export const OfflineService = {
|
||||||
|
|
||||||
hasPendingData: async () => {
|
hasPendingData: async () => {
|
||||||
const tables = ['farmers', 'lands', 'plants', 'harvest', 'plant_inspections', 'offtakers', 'validators'];
|
const tables = ['farmers', 'lands', 'plants', 'harvests', 'plant_inspections', 'offtakers', 'validators', 'distributions','profiles'];
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
const count = await db[table].where('sync_status').equals('pending').count();
|
const count = await db[table].where('sync_status').equals('pending').count();
|
||||||
if (count > 0) return true;
|
if (count > 0) return true;
|
||||||
@@ -44,7 +44,7 @@ export const OfflineService = {
|
|||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Dexie Error:", err);
|
console.error("Error:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -78,8 +78,6 @@ export const OfflineService = {
|
|||||||
if (filter && typeof filter === 'object' && Object.keys(filter).length > 0) {
|
if (filter && typeof filter === 'object' && Object.keys(filter).length > 0) {
|
||||||
return await dbTable.where(filter).toArray();
|
return await dbTable.where(filter).toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Fetching ...... all from ${table}`);
|
|
||||||
return await dbTable.reverse().toArray();
|
return await dbTable.reverse().toArray();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -93,9 +91,11 @@ export const OfflineService = {
|
|||||||
db.farmers.where('sync_status').equals('pending').count(),
|
db.farmers.where('sync_status').equals('pending').count(),
|
||||||
db.lands.where('sync_status').equals('pending').count(),
|
db.lands.where('sync_status').equals('pending').count(),
|
||||||
db.plants.where('sync_status').equals('pending').count(),
|
db.plants.where('sync_status').equals('pending').count(),
|
||||||
db.harvest.where('sync_status').equals('pending').count(),
|
db.harvests.where('sync_status').equals('pending').count(),
|
||||||
db.plant_inspections.where('sync_status').equals('pending').count(),
|
db.plant_inspections.where('sync_status').equals('pending').count(),
|
||||||
db.offtakers.where('sync_status').equals('pending').count(),
|
db.offtakers.where('sync_status').equals('pending').count(),
|
||||||
|
db.profiles.where('sync_status').equals('pending').count(),
|
||||||
|
db.distributions.where('sync_status').equals('pending').count(),
|
||||||
// db.validators.where('sync_status').equals('pending').count(),
|
// db.validators.where('sync_status').equals('pending').count(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -126,6 +126,50 @@ export const OfflineService = {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
syncDataFromServer : async () => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const baseURL = import.meta.env.VITE_BASE44_API_URL.replace(/\/+$/, "");
|
||||||
|
const syncConfigs = [
|
||||||
|
{ from: db.farmers, to:"profile", endpoint: 'auth/profile/1' },
|
||||||
|
{ from: db.lands, to:"lahan", endpoint: 'map/lahan' },
|
||||||
|
{ from: db.plants, to:"tanaman", endpoint: 'map/tanaman' },
|
||||||
|
{ from: db.harvests, to:"panen", endpoint: 'map/panen'},
|
||||||
|
{ from: db.plant_inspections, to:"inspeksi", endpoint: 'map/inspeksi'},
|
||||||
|
{ from: db.offtakers, to:"offtaker", endpoint: 'map/offtaker'},
|
||||||
|
{ from: db.distributions, to:"panen", endpoint: 'distribusi/panen'},
|
||||||
|
{ from: db.villages, to: "desa-kelurahan", endpoint: "master/desa-kelurahan" },
|
||||||
|
{ from: db.districts, to: "kecamatan", endpoint: "master/kecamatan"},
|
||||||
|
{ from: db.regencies, to: "kabupaten-kota", endpoint: "master/kabupaten-kota"},
|
||||||
|
{ from: db.provinces, to: "provinsi", endpoint: "master/provinsi"},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const config of syncConfigs) {
|
||||||
|
try {
|
||||||
|
console.log(`Syncing table: ${config.from.name}...`);
|
||||||
|
|
||||||
|
const response = await axios.get(`${baseURL}/api/${config.endpoint}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
console.log(response, "Res")
|
||||||
|
if (response.status !== 200) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
|
||||||
|
const result = await response;
|
||||||
|
const dataToSave = config.to ? result[config.to] : (result.data || result.data.data || []);
|
||||||
|
|
||||||
|
if (Array.isArray(dataToSave)) {
|
||||||
|
await config.table.bulkPut(dataToSave);
|
||||||
|
console.log(`Berhasil sinkron ${dataToSave.length} data ke ${config.table.name}`);
|
||||||
|
} else if (typeof dataToSave === 'object' && dataToSave !== null) {
|
||||||
|
await config.table.put(dataToSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Gagal sinkronisasi ${config.endpoint}:`, error);
|
||||||
|
// Lanjut ke config berikutnya meski satu error
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
syncAll: async () => {
|
syncAll: async () => {
|
||||||
const queue = await db.pending_sync.toArray();
|
const queue = await db.pending_sync.toArray();
|
||||||
for (const item of queue) {
|
for (const item of queue) {
|
||||||
@@ -148,17 +192,18 @@ export const OfflineService = {
|
|||||||
console.log(user, "Userr")
|
console.log(user, "Userr")
|
||||||
const syncConfigs = [
|
const syncConfigs = [
|
||||||
{ endpoint: `auth/profile/1`, table: db.farmers },
|
{ endpoint: `auth/profile/1`, table: db.farmers },
|
||||||
|
// { endpoint: `auth/me`, table: db.users },
|
||||||
{ endpoint: 'map/lahan', table: db.lands },
|
{ endpoint: 'map/lahan', table: db.lands },
|
||||||
{ endpoint: 'map/tanaman', table: db.plants },
|
{ endpoint: 'map/tanaman', table: db.plants },
|
||||||
{ endpoint: 'map/panen', table: db.harvest},
|
{ endpoint: 'map/panen', table: db.harvests},
|
||||||
{ endpoint: 'map/inspeksi', table: db.plant_inspections},
|
{ endpoint: 'map/inspeksi', table: db.plant_inspections},
|
||||||
{ endpoint: 'map/offtaker', table: db.offtakers},
|
{ endpoint: 'map/offtaker', table: db.offtakers},
|
||||||
{ endpoint: 'distribusi/panen', table: db.distributions},
|
{ endpoint: 'distribusi/panen', table: db.distributions},
|
||||||
// { endpoint: 'map/validator', table: db.validators}
|
// { endpoint: 'map/validator', table: db.validators}
|
||||||
{ table: db.villages, to: "desa-kelurahan", endpoint: "master/desa-kelurahan" },
|
{ endpoint: "master/desa-kelurahan", table: db.villages, to: "desa-kelurahan"},
|
||||||
{ table: db.districts, to: "kecamatan", endpoint: "master/kecamatan"},
|
{ endpoint: "master/kecamatan", table: db.districts, to: "kecamatan"},
|
||||||
{ table: db.regencies, to: "kabupaten-kota", endpoint: "master/kabupaten-kota"},
|
{ endpoint: "master/kabupaten-kota", table: db.regencies, to: "kabupaten-kota"},
|
||||||
{ table: db.provinces, to: "provinsi", endpoint: "master/provinsi"},
|
{ endpoint: "master/provinsi", table: db.provinces, to: "provinsi"},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -168,34 +213,55 @@ export const OfflineService = {
|
|||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
|
|
||||||
const respondData = Array.isArray(respond.data) ? respond.data : respond.data.data || [];
|
const rawData = respond.data?.data || respond.data;
|
||||||
if(respondData.length > 0){
|
console.log(rawData, "Raw Data")
|
||||||
const localIds = await config.table.toCollection().primaryKeys();
|
let respondData = [];
|
||||||
const newData = respondData.filter(item => !localIds.includes(item.id));
|
|
||||||
if(newData.length > 0){
|
// const respondData = (config.endpoint.includes('auth/profile') || config.endpoint.includes('auth/me'))
|
||||||
await config.table.bulkAdd(newData.map(item => ({
|
// ? [rawData]
|
||||||
...item,
|
// : Array.isArray(rawData) ? rawData : [];
|
||||||
sync_status: 'synced'
|
|
||||||
})));
|
if (config.endpoint.includes('auth/profile') || config.endpoint.includes('auth/me')) {
|
||||||
|
if (rawData && typeof rawData === 'object') {
|
||||||
|
const { password, ...cleanData } = rawData;
|
||||||
|
respondData = [cleanData]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// console.log(` Data ${config.endpoint} sudah mutakhir (tidak ada data baru).`);
|
// Jika data memang seharusnya array (List)
|
||||||
|
respondData = Array.isArray(rawData) ? rawData : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(respondData," || data " ,config.table)
|
||||||
|
if(respondData.length > 0 && respondData[0] !== null){
|
||||||
|
const preparedData = respondData.map(item => ({
|
||||||
|
...item,
|
||||||
|
id: item.id,
|
||||||
|
sync_status: 'synced'
|
||||||
|
})).filter(item => item.id !== undefined);
|
||||||
|
|
||||||
|
if (preparedData.length > 0) {
|
||||||
|
await config.table.bulkPut(preparedData);
|
||||||
|
console.log(`✅ Berhasil sync ${preparedData.length} data ke ${config.table.name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Gagal download data:", err);
|
console.error("Gagal download data:", err);
|
||||||
|
if (err.failures) {
|
||||||
|
console.error("Detail kegagalan Dexie:", err.failures);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
syncAllPending: async () => {
|
syncAllPending: async () => {
|
||||||
const tables = [
|
const tables = [
|
||||||
{ from: db.farmers, to:"profile", endpoint: 'auth/profile' },
|
{ from: db.farmers, to:"profile", endpoint: 'auth/profile/1' },
|
||||||
{ from: db.lands, to:"lahan", endpoint: 'map/lahan' },
|
{ from: db.lands, to:"lahan", endpoint: 'map/lahan' },
|
||||||
{ from: db.plants, to:"tanaman", endpoint: 'map/tanaman' },
|
{ from: db.plants, to:"tanaman", endpoint: 'map/tanaman' },
|
||||||
{ endpoint: 'map/panen', to:"panen", from: db.harvest},
|
{ from: db.harvests, to:"panen", endpoint: 'map/panen'},
|
||||||
{ endpoint: 'map/inspeksi', to:"inspeksi", from: db.plant_inspections},
|
{ from: db.plant_inspections, to:"inspeksi", endpoint: 'map/inspeksi'},
|
||||||
{ endpoint: 'map/offtaker', to:"offtaker", from: db.offtakers},
|
{ from: db.offtakers, to:"offtaker", endpoint: 'map/offtaker'},
|
||||||
{ endpoint: 'distribusi/panen', to:"panen", from: db.distributions},
|
{ from: db.distributions, to:"panen", endpoint: 'distribusi/panen'},
|
||||||
// { endpoint: 'map/validator', to:"validator", from: db.validators}
|
// { endpoint: 'map/validator', to:"validator", from: db.validators}
|
||||||
{ from: db.villages, to: "desa-kelurahan", endpoint: "master/desa-kelurahan" },
|
{ from: db.villages, to: "desa-kelurahan", endpoint: "master/desa-kelurahan" },
|
||||||
{ from: db.districts, to: "kecamatan", endpoint: "master/kecamatan"},
|
{ from: db.districts, to: "kecamatan", endpoint: "master/kecamatan"},
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+12
-11
@@ -12,18 +12,19 @@ const statusColors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
pending: "Menunggu Verifikasi",
|
pending: "W",
|
||||||
verified: "Terverifikasi",
|
verified: "V",
|
||||||
rejected: "Ditolak"
|
rejected: "X"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FarmerCard({ farmer }) {
|
export default function FarmerCard({ farmer }) {
|
||||||
|
console.log("Petani : ", farmer);
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm hover:shadow-md transition-all p-5 bg-white group">
|
<Card className="border-0 shadow-sm hover:shadow-md transition-all p-5 bg-white group">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-emerald-100 to-emerald-200 flex items-center justify-center flex-shrink-0">
|
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-emerald-100 to-emerald-200 flex items-center justify-center flex-shrink-0">
|
||||||
{farmer.photo_url ? (
|
{farmer.photo_url ? (
|
||||||
<img src={farmer.photo_url} alt={farmer.full_name} className="w-full h-full rounded-full object-cover" />
|
<img src={farmer.photo_url} alt={farmer?.name || farmer?.nama || '-'} className="w-full h-full rounded-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xl font-bold text-emerald-700">
|
<span className="text-xl font-bold text-emerald-700">
|
||||||
{farmer.full_name?.charAt(0)?.toUpperCase() || "P"}
|
{farmer.full_name?.charAt(0)?.toUpperCase() || "P"}
|
||||||
@@ -34,18 +35,18 @@ export default function FarmerCard({ farmer }) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2 mb-2">
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-slate-900 truncate">{farmer.full_name}</h3>
|
<h3 className="font-semibold text-slate-900 truncate">{farmer?.full_name || farmer?.name || farmer?.nama }</h3>
|
||||||
<p className="text-sm text-slate-500">NIK: {farmer.nik}</p>
|
<p className="text-sm text-slate-500">NIK: {farmer?.kk}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={`${statusColors[farmer.verification_status]} border font-medium text-xs`}>
|
<Badge className={`${statusColors[farmer.user.email_verified_at !== null || farmer.user.email_verified_at !== undefined ? 'verified' : 'pending']} border font-medium text-xs`}>
|
||||||
{statusLabels[farmer.verification_status]}
|
{statusLabels[farmer.user.email_verified_at !== null || farmer.user.email_verified_at !== undefined ? 'verified' : 'pending']}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 text-sm text-slate-600">
|
<div className="space-y-1.5 text-sm text-slate-600">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPin className="w-4 h-4 text-slate-400" />
|
<MapPin className="w-4 h-4 text-slate-400" />
|
||||||
<span className="truncate">{farmer.village}, {farmer.district}</span>
|
<span className="truncate">{farmer.alamat}</span>
|
||||||
</div>
|
</div>
|
||||||
{farmer.farmer_group && (
|
{farmer.farmer_group && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -53,10 +54,10 @@ export default function FarmerCard({ farmer }) {
|
|||||||
<span className="truncate">{farmer.farmer_group}</span>
|
<span className="truncate">{farmer.farmer_group}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{farmer.phone && (
|
{farmer.telepon && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Phone className="w-4 h-4 text-slate-400" />
|
<Phone className="w-4 h-4 text-slate-400" />
|
||||||
<span>{farmer.phone}</span>
|
<span>{farmer?.telepon}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
+242
@@ -0,0 +1,242 @@
|
|||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { MapContainer, TileLayer, Marker, Polygon, useMap, Circle } from "react-leaflet";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Locate, MapPin, Trash2, Save, Navigation, RotateCcw, AlertTriangle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
// Fix default marker icon
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
|
||||||
|
iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
|
||||||
|
shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
const getColorByIndex = (index) => {
|
||||||
|
const colors = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6"];
|
||||||
|
return colors[index % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalkulasi Luas Lahan (Hektar) yang lebih presisi
|
||||||
|
*/
|
||||||
|
function calculatePolygonArea(points) {
|
||||||
|
if (points.length < 3) return 0;
|
||||||
|
|
||||||
|
const radius = 6378137; // Radius Bumi (meters)
|
||||||
|
let area = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[(i + 1) % points.length];
|
||||||
|
|
||||||
|
// Konversi ke Radians
|
||||||
|
const lat1 = p1[0] * Math.PI / 180;
|
||||||
|
const lon1 = p1[1] * Math.PI / 180;
|
||||||
|
const lat2 = p2[0] * Math.PI / 180;
|
||||||
|
const lon2 = p2[1] * Math.PI / 180;
|
||||||
|
|
||||||
|
area += (lon2 - lon1) * (2 + Math.sin(lat1) + Math.sin(lat2));
|
||||||
|
}
|
||||||
|
|
||||||
|
area = Math.abs(area * radius * radius / 2.0);
|
||||||
|
return area / 10000; // Return in Hectares
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapController({ center, zoom }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (center) {
|
||||||
|
// Zoom 20 untuk melihat detail batas lahan lebih dekat
|
||||||
|
map.flyTo(center, zoom || 20, { duration: 1.5 });
|
||||||
|
}
|
||||||
|
}, [center, map, zoom]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GPSLandMapper({ existingPolygon, onSave, readOnly = false }) {
|
||||||
|
const [points, setPoints] = useState([]);
|
||||||
|
const [currentLocation, setCurrentLocation] = useState(null);
|
||||||
|
const [isLocating, setIsLocating] = useState(false);
|
||||||
|
const [gpsAccuracy, setGpsAccuracy] = useState(null);
|
||||||
|
const [watchId, setWatchId] = useState(null);
|
||||||
|
|
||||||
|
const area = useMemo(() => calculatePolygonArea(points), [points]);
|
||||||
|
|
||||||
|
const getDistance = (p1, p2) => {
|
||||||
|
if (!p1 || !p2) return 0;
|
||||||
|
const R = 6371000;
|
||||||
|
const dLat = (p2[0] - p1[0]) * Math.PI / 180;
|
||||||
|
const dLon = (p2[1] - p1[1]) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(p1[0] * Math.PI / 180) * Math.cos(p2[0] * Math.PI / 180) *
|
||||||
|
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingPolygon && existingPolygon.length > 0) {
|
||||||
|
// Pastikan format dari database [lng, lat] dibalik ke [lat, lng] untuk Leaflet
|
||||||
|
const converted = Array.isArray(existingPolygon[0])
|
||||||
|
? existingPolygon.map(p => [p[1], p[0]])
|
||||||
|
: [];
|
||||||
|
setPoints(converted);
|
||||||
|
}
|
||||||
|
}, [existingPolygon]);
|
||||||
|
|
||||||
|
const startGPSTracking = () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
toast.error("GPS tidak didukung");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLocating(true);
|
||||||
|
const id = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => {
|
||||||
|
setCurrentLocation([pos.coords.latitude, pos.coords.longitude]);
|
||||||
|
setGpsAccuracy(pos.coords.accuracy);
|
||||||
|
setIsLocating(false);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setIsLocating(false);
|
||||||
|
toast.error("Gagal mendapatkan lokasi GPS");
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 0 }
|
||||||
|
);
|
||||||
|
setWatchId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopGPSTracking = () => {
|
||||||
|
if (watchId !== null) navigator.geolocation.clearWatch(watchId);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startGPSTracking();
|
||||||
|
return () => stopGPSTracking();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addCornerPoint = () => {
|
||||||
|
if (!currentLocation) {
|
||||||
|
toast.error("Tunggu GPS mengunci lokasi");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gpsAccuracy > 10) {
|
||||||
|
toast.error(`Akurasi buruk (±${gpsAccuracy.toFixed(0)}m). Pindah ke tempat terbuka.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (points.length > 0) {
|
||||||
|
const dist = getDistance(points[points.length - 1], currentLocation);
|
||||||
|
if (dist < 1) {
|
||||||
|
toast.error("Anda belum bergerak dari titik terakhir");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPoints([...points, currentLocation]);
|
||||||
|
toast.success(`Titik ${points.length + 1} ditandai`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLastPoint = () => setPoints(points.slice(0, -1));
|
||||||
|
const clearAllPoints = () => setPoints([]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (points.length < 3) {
|
||||||
|
toast.error("Minimal harus ada 3 titik");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave({
|
||||||
|
polygon_coordinates: points.map(p => [p[1], p[0]]), // [lng, lat]
|
||||||
|
center_lat: points.reduce((s, p) => s + p[0], 0) / points.length,
|
||||||
|
center_lng: points.reduce((s, p) => s + p[1], 0) / points.length,
|
||||||
|
area_hectares: area
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCornerIcon = (index) => L.divIcon({
|
||||||
|
className: "custom-corner-icon",
|
||||||
|
html: `<div style="width:32px;height:32px;background:${getColorByIndex(index)};border-radius:50%;border:3px solid white;display:flex;align-items:center;justify-content:center;color:white;font-weight:bold;">${index + 1}</div>`,
|
||||||
|
iconSize: [32, 32], iconAnchor: [16, 16]
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 bg-slate-50 border-b space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${gpsAccuracy <= 5 ? "bg-emerald-500" : "bg-amber-500"} animate-pulse`} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">GPS {currentLocation ? "Aktif" : "Mencari..."}</p>
|
||||||
|
<p className={`text-xs font-bold ${gpsAccuracy > 10 ? 'text-red-500' : 'text-slate-500'}`}>
|
||||||
|
Akurasi: ±{gpsAccuracy?.toFixed(1)}m {gpsAccuracy > 10 && "(Lemah)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currentLocation && <Badge variant="outline">{currentLocation[0].toFixed(5)}, {currentLocation[1].toFixed(5)}</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={addCornerPoint} disabled={!currentLocation} className="flex-1 bg-emerald-600 h-12">
|
||||||
|
<Navigation className="w-5 h-5 mr-2" /> Tandai Titik {points.length + 1}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={removeLastPoint} disabled={points.length === 0} className="h-12"><RotateCcw /></Button>
|
||||||
|
<Button variant="outline" onClick={clearAllPoints} disabled={points.length === 0} className="h-12"><Trash2 /></Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{points.length >= 3 && (
|
||||||
|
<div className="p-4 bg-emerald-600 text-white rounded-xl flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs opacity-80 uppercase tracking-wider">Estimasi Luas</p>
|
||||||
|
<p className="text-2xl font-bold">{area.toFixed(4)} Ha</p>
|
||||||
|
</div>
|
||||||
|
{!readOnly && <Button onClick={handleSave} className="bg-white text-emerald-700 hover:bg-emerald-50"><Save className="mr-2 h-4 w-4"/> Simpan</Button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-[450px] relative">
|
||||||
|
<MapContainer center={currentLocation || [-6.2, 106.8]} zoom={18} className="h-full w-full">
|
||||||
|
{/* LAYER SATELIT UNTUK PRESISI */}
|
||||||
|
<TileLayer
|
||||||
|
attribution='© Esri World Imagery'
|
||||||
|
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MapController center={currentLocation} />
|
||||||
|
|
||||||
|
{currentLocation && (
|
||||||
|
<>
|
||||||
|
<Circle
|
||||||
|
center={currentLocation}
|
||||||
|
radius={gpsAccuracy}
|
||||||
|
pathOptions={{ color: 'transparent', fillColor: '#3b82f6', fillOpacity: 0.2 }}
|
||||||
|
/>
|
||||||
|
<Marker position={currentLocation} icon={L.divIcon({
|
||||||
|
className: "loc-icon",
|
||||||
|
html: `<div style="width:20px;height:20px;background:#3b82f6;border-radius:50%;border:3px solid white;box-shadow:0 0 10px rgba(0,0,0,0.3);"></div>`,
|
||||||
|
iconSize:[20,20], iconAnchor:[10,10]
|
||||||
|
})} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{points.map((p, i) => <Marker key={i} position={p} icon={createCornerIcon(i)} />)}
|
||||||
|
{points.length >= 3 && <Polygon positions={points} pathOptions={{ color: "#22c55e", fillColor: "#22c55e", fillOpacity: 0.3, weight: 3 }} />}
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 bg-white/90 backdrop-blur p-3 rounded-lg shadow-md z-[1000] border-l-4 border-emerald-500">
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
<span className="font-bold">Tips:</span> Berjalanlah ke setiap sudut lahan. Tunggu lingkaran biru mengecil sebelum menekan "Tandai Titik" untuk hasil yang lebih presisi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
+186
-182
@@ -3,146 +3,52 @@ import { MapContainer, TileLayer, Marker, Polygon, useMap, Circle } from "react-
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Locate, MapPin, Trash2, Save, Navigation, RotateCcw, AlertTriangle } from "lucide-react";
|
import { Trash2, Save, Navigation, RotateCcw, Zap, ZapOff, Map as MapIcon, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
|
import { Geolocation } from '@capacitor/geolocation';
|
||||||
|
|
||||||
// Fix default marker icon
|
// Konfigurasi Icon Marker agar muncul di Android
|
||||||
delete L.Icon.Default.prototype._getIconUrl;
|
const customMarkerIcon = new L.Icon({
|
||||||
L.Icon.Default.mergeOptions({
|
|
||||||
iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
|
|
||||||
iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
|
iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
|
||||||
|
iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
|
||||||
shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
|
shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
});
|
});
|
||||||
|
|
||||||
const getColorByIndex = (index) => {
|
const getColorByIndex = (index) => {
|
||||||
const colors = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6"];
|
const colors = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6"];
|
||||||
return colors[index % colors.length];
|
return colors[index % colors.length];
|
||||||
};
|
};
|
||||||
|
// Komponen Pengatur Peta (PENTING: Memperbaiki masalah layar putih)
|
||||||
/**
|
function MapController({ center, isTracking }) {
|
||||||
* Kalkulasi Luas Lahan (Hektar) yang lebih presisi
|
|
||||||
*/
|
|
||||||
function calculatePolygonArea(points) {
|
|
||||||
if (points.length < 3) return 0;
|
|
||||||
|
|
||||||
const radius = 6378137; // Radius Bumi (meters)
|
|
||||||
let area = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
const p1 = points[i];
|
|
||||||
const p2 = points[(i + 1) % points.length];
|
|
||||||
|
|
||||||
// Konversi ke Radians
|
|
||||||
const lat1 = p1[0] * Math.PI / 180;
|
|
||||||
const lon1 = p1[1] * Math.PI / 180;
|
|
||||||
const lat2 = p2[0] * Math.PI / 180;
|
|
||||||
const lon2 = p2[1] * Math.PI / 180;
|
|
||||||
|
|
||||||
area += (lon2 - lon1) * (2 + Math.sin(lat1) + Math.sin(lat2));
|
|
||||||
}
|
|
||||||
|
|
||||||
area = Math.abs(area * radius * radius / 2.0);
|
|
||||||
return area / 10000; // Return in Hectares
|
|
||||||
}
|
|
||||||
|
|
||||||
function MapController({ center, zoom }) {
|
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (center) {
|
// Memaksa Leaflet menghitung ulang ukuran container setelah render pertama
|
||||||
// Zoom 20 untuk melihat detail batas lahan lebih dekat
|
setTimeout(() => {
|
||||||
map.flyTo(center, zoom || 20, { duration: 1.5 });
|
map.invalidateSize();
|
||||||
|
}, 500);
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (center && isTracking) {
|
||||||
|
map.setView(center, 19, { animate: true });
|
||||||
}
|
}
|
||||||
}, [center, map, zoom]);
|
}, [center, isTracking, map]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GPSLandMapper({ existingPolygon, onSave, readOnly = false }) {
|
export default function GPSLandMapper({ onSave, readOnly = false }) {
|
||||||
const [points, setPoints] = useState([]);
|
const [points, setPoints] = useState([]);
|
||||||
const [currentLocation, setCurrentLocation] = useState(null);
|
const [currentLocation, setCurrentLocation] = useState(null);
|
||||||
const [isLocating, setIsLocating] = useState(false);
|
|
||||||
const [gpsAccuracy, setGpsAccuracy] = useState(null);
|
const [gpsAccuracy, setGpsAccuracy] = useState(null);
|
||||||
const [watchId, setWatchId] = useState(null);
|
const [watchId, setWatchId] = useState(null);
|
||||||
|
const [isTracking, setIsTracking] = useState(false);
|
||||||
const area = useMemo(() => calculatePolygonArea(points), [points]);
|
const [positionBuffer, setPositionBuffer] = useState([]);
|
||||||
|
|
||||||
const getDistance = (p1, p2) => {
|
|
||||||
if (!p1 || !p2) return 0;
|
|
||||||
const R = 6371000;
|
|
||||||
const dLat = (p2[0] - p1[0]) * Math.PI / 180;
|
|
||||||
const dLon = (p2[1] - p1[1]) * Math.PI / 180;
|
|
||||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
||||||
Math.cos(p1[0] * Math.PI / 180) * Math.cos(p2[0] * Math.PI / 180) *
|
|
||||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
||||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (existingPolygon && existingPolygon.length > 0) {
|
|
||||||
// Pastikan format dari database [lng, lat] dibalik ke [lat, lng] untuk Leaflet
|
|
||||||
const converted = Array.isArray(existingPolygon[0])
|
|
||||||
? existingPolygon.map(p => [p[1], p[0]])
|
|
||||||
: [];
|
|
||||||
setPoints(converted);
|
|
||||||
}
|
|
||||||
}, [existingPolygon]);
|
|
||||||
|
|
||||||
const startGPSTracking = () => {
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
toast.error("GPS tidak didukung");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLocating(true);
|
|
||||||
const id = navigator.geolocation.watchPosition(
|
|
||||||
(pos) => {
|
|
||||||
setCurrentLocation([pos.coords.latitude, pos.coords.longitude]);
|
|
||||||
setGpsAccuracy(pos.coords.accuracy);
|
|
||||||
setIsLocating(false);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
setIsLocating(false);
|
|
||||||
toast.error("Gagal mendapatkan lokasi GPS");
|
|
||||||
},
|
|
||||||
{ enableHighAccuracy: true, maximumAge: 0 }
|
|
||||||
);
|
|
||||||
setWatchId(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopGPSTracking = () => {
|
|
||||||
if (watchId !== null) navigator.geolocation.clearWatch(watchId);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
startGPSTracking();
|
|
||||||
return () => stopGPSTracking();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addCornerPoint = () => {
|
|
||||||
if (!currentLocation) {
|
|
||||||
toast.error("Tunggu GPS mengunci lokasi");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gpsAccuracy > 10) {
|
|
||||||
toast.error(`Akurasi buruk (±${gpsAccuracy.toFixed(0)}m). Pindah ke tempat terbuka.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (points.length > 0) {
|
|
||||||
const dist = getDistance(points[points.length - 1], currentLocation);
|
|
||||||
if (dist < 1) {
|
|
||||||
toast.error("Anda belum bergerak dari titik terakhir");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPoints([...points, currentLocation]);
|
|
||||||
toast.success(`Titik ${points.length + 1} ditandai`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLastPoint = () => setPoints(points.slice(0, -1));
|
|
||||||
const clearAllPoints = () => setPoints([]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (points.length < 3) {
|
if (points.length < 3) {
|
||||||
@@ -157,83 +63,181 @@ export default function GPSLandMapper({ existingPolygon, onSave, readOnly = fals
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCornerIcon = (index) => L.divIcon({
|
// Hitung Luas
|
||||||
className: "custom-corner-icon",
|
const area = useMemo(() => {
|
||||||
html: `<div style="width:32px;height:32px;background:${getColorByIndex(index)};border-radius:50%;border:3px solid white;display:flex;align-items:center;justify-content:center;color:white;font-weight:bold;">${index + 1}</div>`,
|
if (points.length < 3) return 0;
|
||||||
iconSize: [32, 32], iconAnchor: [16, 16]
|
const radius = 6378137;
|
||||||
});
|
let areaVal = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[(i + 1) % points.length];
|
||||||
|
const lat1 = p1[0] * Math.PI / 180;
|
||||||
|
const lon1 = p1[1] * Math.PI / 180;
|
||||||
|
const lat2 = p2[0] * Math.PI / 180;
|
||||||
|
const lon2 = p2[1] * Math.PI / 180;
|
||||||
|
areaVal += (lon2 - lon1) * (2 + Math.sin(lat1) + Math.sin(lat2));
|
||||||
|
}
|
||||||
|
return Math.abs(areaVal * radius * radius / 2.0) / 10000;
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
const startGPSTracking = async () => {
|
||||||
|
try {
|
||||||
|
const permissions = await Geolocation.requestPermissions();
|
||||||
|
if (permissions.location !== 'granted') {
|
||||||
|
toast.error("Izin lokasi diperlukan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await Geolocation.watchPosition(
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 },
|
||||||
|
(position, err) => {
|
||||||
|
if (err) return;
|
||||||
|
if (position) {
|
||||||
|
const coords = [position.coords.latitude, position.coords.longitude];
|
||||||
|
setCurrentLocation(coords);
|
||||||
|
setGpsAccuracy(position.coords.accuracy);
|
||||||
|
setPositionBuffer(prev => [...prev, coords].slice(-5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setWatchId(id);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("GPS Error: " + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopGPSTracking = async () => {
|
||||||
|
if (watchId) {
|
||||||
|
await Geolocation.clearWatch({ id: watchId });
|
||||||
|
setWatchId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTracking) startGPSTracking();
|
||||||
|
else stopGPSTracking();
|
||||||
|
return () => stopGPSTracking();
|
||||||
|
}, [isTracking]);
|
||||||
|
|
||||||
|
const addPoint = () => {
|
||||||
|
if (!currentLocation) return;
|
||||||
|
if (gpsAccuracy > 15) {
|
||||||
|
toast.error("Akurasi terlalu rendah, tunggu sinyal stabil");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Averaging
|
||||||
|
const lat = positionBuffer.reduce((a, b) => a + b[0], 0) / positionBuffer.length;
|
||||||
|
const lng = positionBuffer.reduce((a, b) => a + b[1], 0) / positionBuffer.length;
|
||||||
|
setPoints([...points, [lat, lng]]);
|
||||||
|
toast.success("Titik sudut ditambahkan");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm overflow-hidden">
|
<Card className="w-full border-0 shadow-none bg-background flex flex-col h-[92vh]">
|
||||||
<div className="p-4 bg-slate-50 border-b space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
{/* SECTION 1: KONTROL UTAMA (Selalu Ada) */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="p-4 space-y-3 bg-white z-[2000]">
|
||||||
<div className={`w-3 h-3 rounded-full ${gpsAccuracy <= 5 ? "bg-emerald-500" : "bg-amber-500"} animate-pulse`} />
|
<div className="flex justify-between items-center mb-1">
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm font-medium">GPS {currentLocation ? "Aktif" : "Mencari..."}</p>
|
<div className={`w-2.5 h-2.5 rounded-full ${isTracking ? 'bg-green-500 animate-pulse' : 'bg-slate-300'}`} />
|
||||||
<p className={`text-xs font-bold ${gpsAccuracy > 10 ? 'text-red-500' : 'text-slate-500'}`}>
|
<span className="text-xs font-bold text-slate-600 uppercase tracking-wide">
|
||||||
Akurasi: ±{gpsAccuracy?.toFixed(1)}m {gpsAccuracy > 10 && "(Lemah)"}
|
{isTracking ? `Akurasi: ±${gpsAccuracy?.toFixed(1)}m` : "GPS Nonaktif"}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{currentLocation && <Badge variant="outline" className="text-[10px] opacity-70">Sinyal Terkunci</Badge>}
|
||||||
{currentLocation && <Badge variant="outline">{currentLocation[0].toFixed(5)}, {currentLocation[1].toFixed(5)}</Badge>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnly && (
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={addCornerPoint} disabled={!currentLocation} className="flex-1 bg-emerald-600 h-12">
|
{!isTracking ? (
|
||||||
<Navigation className="w-5 h-5 mr-2" /> Tandai Titik {points.length + 1}
|
<Button onClick={() => setIsTracking(true)} className="flex-1 h-12 bg-blue-600 shadow-sm font-bold">
|
||||||
|
<Zap className="mr-2 h-4 w-4" /> Mulai Ukur Lahan
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={removeLastPoint} disabled={points.length === 0} className="h-12"><RotateCcw /></Button>
|
) : (
|
||||||
<Button variant="outline" onClick={clearAllPoints} disabled={points.length === 0} className="h-12"><Trash2 /></Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{points.length >= 3 && (
|
|
||||||
<div className="p-4 bg-emerald-600 text-white rounded-xl flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs opacity-80 uppercase tracking-wider">Estimasi Luas</p>
|
|
||||||
<p className="text-2xl font-bold">{area.toFixed(4)} Ha</p>
|
|
||||||
</div>
|
|
||||||
{!readOnly && <Button onClick={handleSave} className="bg-white text-emerald-700 hover:bg-emerald-50"><Save className="mr-2 h-4 w-4"/> Simpan</Button>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-[450px] relative">
|
|
||||||
<MapContainer center={currentLocation || [-6.2, 106.8]} zoom={18} className="h-full w-full">
|
|
||||||
{/* LAYER SATELIT UNTUK PRESISI */}
|
|
||||||
<TileLayer
|
|
||||||
attribution='© Esri World Imagery'
|
|
||||||
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MapController center={currentLocation} />
|
|
||||||
|
|
||||||
{currentLocation && (
|
|
||||||
<>
|
<>
|
||||||
<Circle
|
<Button onClick={addPoint} className="flex-1 h-12 bg-emerald-600 hover:bg-emerald-700 text-white shadow-md font-bold">
|
||||||
center={currentLocation}
|
<Navigation className="mr-2 h-4 w-4" /> Tandai Sudut ({points.length})
|
||||||
radius={gpsAccuracy}
|
</Button>
|
||||||
pathOptions={{ color: 'transparent', fillColor: '#3b82f6', fillOpacity: 0.2 }}
|
<Button variant="outline" onClick={() => setIsTracking(false)} className="h-12 w-12 border-red-200 text-red-500 hover:bg-red-50">
|
||||||
/>
|
<ZapOff className="h-5 w-5" />
|
||||||
<Marker position={currentLocation} icon={L.divIcon({
|
</Button>
|
||||||
className: "loc-icon",
|
|
||||||
html: `<div style="width:20px;height:20px;background:#3b82f6;border-radius:50%;border:3px solid white;box-shadow:0 0 10px rgba(0,0,0,0.3);"></div>`,
|
|
||||||
iconSize:[20,20], iconAnchor:[10,10]
|
|
||||||
})} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{points.map((p, i) => <Marker key={i} position={p} icon={createCornerIcon(i)} />)}
|
{/* SECTION 2: AREA INFORMASI LUAS & SIMPAN (Muncul jika titik >= 3) */}
|
||||||
{points.length >= 3 && <Polygon positions={points} pathOptions={{ color: "#22c55e", fillColor: "#22c55e", fillOpacity: 0.3, weight: 3 }} />}
|
{points.length >= 3 && (
|
||||||
</MapContainer>
|
<div className="mx-4 mb-4 p-4 bg-emerald-50 border border-emerald-100 rounded-2xl flex items-center justify-between animate-in fade-in slide-in-from-top-2 duration-300 z-[2000]">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[10px] font-bold text-emerald-600 uppercase tracking-wider">Total Estimasi Luas</p>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-2xl font-black text-emerald-900">{area.toFixed(4)}</span>
|
||||||
|
<span className="text-sm font-bold text-emerald-700">Ha</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="absolute bottom-4 left-4 right-4 bg-white/90 backdrop-blur p-3 rounded-lg shadow-md z-[1000] border-l-4 border-emerald-500">
|
<Button
|
||||||
<p className="text-xs text-slate-600">
|
onClick={() => onSave({ points, area })}
|
||||||
<span className="font-bold">Tips:</span> Berjalanlah ke setiap sudut lahan. Tunggu lingkaran biru mengecil sebelum menekan "Tandai Titik" untuk hasil yang lebih presisi.
|
className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg px-6 h-11 rounded-xl font-bold"
|
||||||
</p>
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" /> Simpan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SECTION 3: MAP CONTAINER */}
|
||||||
|
<div className="flex-1 relative w-full overflow-hidden bg-slate-100 border-t">
|
||||||
|
<MapContainer
|
||||||
|
center={[-6.2, 106.8]}
|
||||||
|
zoom={15}
|
||||||
|
zoomControl={false}
|
||||||
|
style={{ height: "100%", width: "100%", zIndex: 1 }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||||
|
attribution="Esri World Imagery"
|
||||||
|
/>
|
||||||
|
<MapController center={currentLocation} isTracking={isTracking} />
|
||||||
|
|
||||||
|
{currentLocation && isTracking && (
|
||||||
|
<Marker position={currentLocation} icon={customMarkerIcon} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<Marker
|
||||||
|
key={i}
|
||||||
|
position={p}
|
||||||
|
icon={L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="background:${getColorByIndex(i)}; width:24px; height:24px; border-radius:50%; border:2px solid white; color:white; display:flex; align-items:center; justify-content:center; font-weight:bold; font-size:10px; shadow-md">${i+1}</div>`
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{points.length >= 3 && (
|
||||||
|
<Polygon positions={points} pathOptions={{ color: '#10b981', fillColor: '#10b981', fillOpacity: 0.3, weight: 3 }} />
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* Floating Reset Button (Hanya ini yang di dalam peta) */}
|
||||||
|
{points.length > 0 && (
|
||||||
|
<div className="absolute bottom-6 left-4 right-4 flex gap-2 z-[1000]">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1 bg-white/90 backdrop-blur shadow-md"
|
||||||
|
onClick={() => setPoints(points.slice(0, -1))}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" /> Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1 shadow-md"
|
||||||
|
onClick={() => setPoints([])}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" /> Reset
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Regular → Executable
+18
-4
@@ -4,6 +4,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { MapPin, Maximize2, TreePine, ChevronRight } from "lucide-react";
|
import { MapPin, Maximize2, TreePine, ChevronRight } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
|
import { entity } from "@/api/entities";
|
||||||
|
import { OfflineService } from "../common/offlineStorage";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const validationColors = {
|
const validationColors = {
|
||||||
valid: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
valid: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||||
@@ -27,15 +30,26 @@ const statusColors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function LandCard({ land, farmerName, plantCount = 0 }) {
|
export default function LandCard({ land, farmerName, plantCount = 0 }) {
|
||||||
|
const [village, setVillage] = useState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVillages = async () => {
|
||||||
|
const villages = await OfflineService.getEntities('villages');
|
||||||
|
const findvillage = villages.find(data => data.id === land.desa_kelurahan_id);
|
||||||
|
|
||||||
|
setVillage(findvillage);
|
||||||
|
};
|
||||||
|
fetchVillages();
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm hover:shadow-md transition-all p-5 bg-white group">
|
<Card className="border-0 shadow-sm hover:shadow-md transition-all p-5 bg-white group">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-slate-900">{land.nama}</h3>
|
<h3 className="font-semibold text-slate-900">{land.nama || land?.name}</h3>
|
||||||
{farmerName && <p className="text-sm text-slate-500">{farmerName}</p>}
|
{farmerName && <p className="text-sm text-slate-500">{farmerName}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Badge className={`${validationColors[land.validation_status]} border font-medium text-xs`}>
|
<Badge className={`bg-emerald-600 border font-medium text-xs`}> Lahan
|
||||||
{validationLabels[land.validation_status]}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,7 +75,7 @@ export default function LandCard({ land, farmerName, plantCount = 0 }) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
<MapPin className="w-4 h-4" />
|
<MapPin className="w-4 h-4" />
|
||||||
<span className="truncate">{land.village || land.desa_kelurahan_id }, {land.district || 'Unknown'}</span>
|
<span className="truncate">{village?.nama || village?.name || land.desa_kelurahan_id }</span>
|
||||||
</div>
|
</div>
|
||||||
<Link to={createPageUrl("LandDetail") + `?id=${land.id}`}>
|
<Link to={createPageUrl("LandDetail") + `?id=${land.id}`}>
|
||||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 transition-opacity text-emerald-600">
|
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 transition-opacity text-emerald-600">
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user