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>
|
||||
<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" />
|
||||
<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" /> -->
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<title>Agro Monev</title>
|
||||
|
||||
Regular → Executable
+26
@@ -13,6 +13,7 @@
|
||||
"@capacitor/android": "^8.1.0",
|
||||
"@capacitor/cli": "^8.1.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/geolocation": "^8.1.0",
|
||||
"@capacitor/ios": "^8.1.0",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
@@ -52,6 +53,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dexie": "^4.3.0",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
@@ -493,6 +495,18 @@
|
||||
"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": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.1.0.tgz",
|
||||
@@ -502,6 +516,12 @@
|
||||
"@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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||
@@ -4607,6 +4627,12 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"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/cli": "^8.1.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/geolocation": "^8.1.0",
|
||||
"@capacitor/ios": "^8.1.0",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
|
||||
Regular → Executable
+9
@@ -11,6 +11,15 @@
|
||||
},
|
||||
{
|
||||
"path": "../pertamina-point"
|
||||
},
|
||||
{
|
||||
"path": "../dev/wayfinder-project"
|
||||
},
|
||||
{
|
||||
"path": "../own-cloud"
|
||||
},
|
||||
{
|
||||
"path": "../skhp"
|
||||
}
|
||||
],
|
||||
"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 isConnected = await checkInternet();
|
||||
const count = await OfflineService.getPendingCount(); // Ambil dari SQLite
|
||||
const count = await OfflineService.getPendingCount();
|
||||
|
||||
if (mounted) {
|
||||
setOnline(isConnected);
|
||||
@@ -94,7 +94,6 @@ async function checkInternet() {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 3000);
|
||||
// Gunakan favicon google sebagai probe yang ringan
|
||||
await fetch("https://www.google.com/favicon.ico", {
|
||||
method: "HEAD",
|
||||
mode: "no-cors",
|
||||
@@ -109,16 +108,16 @@ async function checkInternet() {
|
||||
|
||||
|
||||
const navigation = [
|
||||
{ name: "Dashboard", page: "Dashboard", icon: LayoutDashboard , roles: ['super_admin']},
|
||||
{ name: "Petani", page: "Farmers", icon: Users, roles: ['super_admin'] },
|
||||
{ name: "Lahan", page: "Lands", icon: Map, roles: ['super_admin'] },
|
||||
{ name: "Tanaman", page: "Plants", icon: TreePine, roles: ['super_admin'] },
|
||||
{ name: "Produktivitas", page: "ProductivityMonitoring", icon: LayoutDashboard, roles: ['super_admin', 'petani'] },
|
||||
{ name: "Validator", page: "Validators", icon: Shield, roles: ['super_admin'] },
|
||||
{ name: "Offtaker", page: "Offtakers", icon: Users, roles: ['super_admin'] },
|
||||
{ name: "Portal Petani", page: "FarmerPortal", icon: Users, roles: ['super_admin', 'petani'] },
|
||||
{ name: "Portal Validator", page: "ValidatorPortal", icon: Shield, roles: ['super_admin'] },
|
||||
{ name: "Portal Offtaker", page: "OfftakerPortal", icon: Users, roles: ['super_admin'] },
|
||||
{ name: "Dashboard", page: "Dashboard", icon: LayoutDashboard , roles: ['super_admin','fasilitator']},
|
||||
{ name: "Petani", page: "Farmers", icon: Users, roles: ['super_admin','fasilitator'] },
|
||||
{ name: "Lahan", page: "Lands", icon: Map, roles: ['super_admin','fasilitator'] },
|
||||
{ name: "Tanaman", page: "Plants", icon: TreePine, roles: ['super_admin','fasilitator'] },
|
||||
{ name: "Produktivitas", page: "ProductivityMonitoring", icon: LayoutDashboard, roles: ['super_admin', 'petani','fasilitator'] },
|
||||
{ name: "Facilitator", page: "Validators", icon: Shield, roles: ['super_admin','fasilitator'] },
|
||||
{ name: "Offtaker", page: "Offtakers", icon: Users, roles: ['super_admin','fasilitator'] },
|
||||
{ name: "Portal Petani", page: "FarmerPortal", icon: Users, roles: ['super_admin', 'petani','fasilitator'] },
|
||||
{ name: "Portal Validator", page: "ValidatorPortal", icon: Shield, roles: ['super_admin','fasilitator'] },
|
||||
{ name: "Portal Offtaker", page: "OfftakerPortal", icon: Users, roles: ['super_admin','fasilitator'] },
|
||||
];
|
||||
|
||||
export default function Layout({ children, currentPageName }) {
|
||||
@@ -148,8 +147,9 @@ export default function Layout({ children, currentPageName }) {
|
||||
<Menu className="w-6 h-6" />
|
||||
</Button>
|
||||
<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">
|
||||
<TreePine className="w-5 h-5 text-white" />
|
||||
<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" /> */}
|
||||
<img src="/logo1.png" alt="" />
|
||||
</div>
|
||||
<span className="font-bold text-slate-900">AgroMonev</span>
|
||||
</div>
|
||||
@@ -173,8 +173,9 @@ export default function Layout({ children, currentPageName }) {
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<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">
|
||||
<TreePine className="w-6 h-6 text-white" />
|
||||
<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" /> */}
|
||||
<img src="/logo1.png" alt="" />
|
||||
</div>
|
||||
<div>
|
||||
<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: "Tanaman", page: "Plants", icon: TreePine },
|
||||
{ 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: "Portal Petani", page: "FarmerPortal", icon: Users },
|
||||
{ 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();
|
||||
|
||||
if (hasPending) {
|
||||
// console.log("📤 Memulai Push Sinkronisasi...");
|
||||
await OfflineService.syncAllPending();
|
||||
toast.success("Sinkronisasi data berhasil");
|
||||
} else {
|
||||
// console.log("🔄 Data lokal bersih. Melakukan Pull dari server...");
|
||||
await OfflineService.downloadFromServer();
|
||||
// await OfflineService.downloadFromServer();
|
||||
await OfflineService.syncDataFromServer();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync process failed:', error);
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
+93
-27
@@ -14,7 +14,7 @@ const agroApi = axios.create({
|
||||
export const OfflineService = {
|
||||
|
||||
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) {
|
||||
const count = await db[table].where('sync_status').equals('pending').count();
|
||||
if (count > 0) return true;
|
||||
@@ -44,7 +44,7 @@ export const OfflineService = {
|
||||
});
|
||||
return id;
|
||||
} catch (err) {
|
||||
console.error("❌ Dexie Error:", err);
|
||||
console.error("Error:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -78,8 +78,6 @@ export const OfflineService = {
|
||||
if (filter && typeof filter === 'object' && Object.keys(filter).length > 0) {
|
||||
return await dbTable.where(filter).toArray();
|
||||
}
|
||||
|
||||
console.log(`Fetching ...... all from ${table}`);
|
||||
return await dbTable.reverse().toArray();
|
||||
|
||||
} catch (e) {
|
||||
@@ -93,9 +91,11 @@ export const OfflineService = {
|
||||
db.farmers.where('sync_status').equals('pending').count(),
|
||||
db.lands.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.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(),
|
||||
]);
|
||||
|
||||
@@ -126,6 +126,50 @@ export const OfflineService = {
|
||||
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 () => {
|
||||
const queue = await db.pending_sync.toArray();
|
||||
for (const item of queue) {
|
||||
@@ -147,18 +191,19 @@ export const OfflineService = {
|
||||
const baseURL = import.meta.env.VITE_BASE44_API_URL.replace(/\/+$/, "");
|
||||
console.log(user, "Userr")
|
||||
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/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/offtaker', table: db.offtakers},
|
||||
{ endpoint: 'distribusi/panen', table: db.distributions},
|
||||
// { endpoint: 'map/validator', table: db.validators}
|
||||
{ table: db.villages, to: "desa-kelurahan", endpoint: "master/desa-kelurahan" },
|
||||
{ table: db.districts, to: "kecamatan", endpoint: "master/kecamatan"},
|
||||
{ table: db.regencies, to: "kabupaten-kota", endpoint: "master/kabupaten-kota"},
|
||||
{ table: db.provinces, to: "provinsi", endpoint: "master/provinsi"},
|
||||
{ endpoint: "master/desa-kelurahan", table: db.villages, to: "desa-kelurahan"},
|
||||
{ endpoint: "master/kecamatan", table: db.districts, to: "kecamatan"},
|
||||
{ endpoint: "master/kabupaten-kota", table: db.regencies, to: "kabupaten-kota"},
|
||||
{ endpoint: "master/provinsi", table: db.provinces, to: "provinsi"},
|
||||
|
||||
];
|
||||
|
||||
@@ -168,34 +213,55 @@ export const OfflineService = {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const respondData = Array.isArray(respond.data) ? respond.data : respond.data.data || [];
|
||||
if(respondData.length > 0){
|
||||
const localIds = await config.table.toCollection().primaryKeys();
|
||||
const newData = respondData.filter(item => !localIds.includes(item.id));
|
||||
if(newData.length > 0){
|
||||
await config.table.bulkAdd(newData.map(item => ({
|
||||
...item,
|
||||
sync_status: 'synced'
|
||||
})));
|
||||
}else {
|
||||
// console.log(` Data ${config.endpoint} sudah mutakhir (tidak ada data baru).`);
|
||||
const rawData = respond.data?.data || respond.data;
|
||||
console.log(rawData, "Raw Data")
|
||||
let respondData = [];
|
||||
|
||||
// const respondData = (config.endpoint.includes('auth/profile') || config.endpoint.includes('auth/me'))
|
||||
// ? [rawData]
|
||||
// : Array.isArray(rawData) ? rawData : [];
|
||||
|
||||
if (config.endpoint.includes('auth/profile') || config.endpoint.includes('auth/me')) {
|
||||
if (rawData && typeof rawData === 'object') {
|
||||
const { password, ...cleanData } = rawData;
|
||||
respondData = [cleanData]
|
||||
}
|
||||
} else {
|
||||
// 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) {
|
||||
console.error("Gagal download data:", err);
|
||||
if (err.failures) {
|
||||
console.error("Detail kegagalan Dexie:", err.failures);
|
||||
}
|
||||
}
|
||||
},
|
||||
syncAllPending: async () => {
|
||||
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.plants, to:"tanaman", endpoint: 'map/tanaman' },
|
||||
{ endpoint: 'map/panen', to:"panen", from: db.harvest},
|
||||
{ endpoint: 'map/inspeksi', to:"inspeksi", from: db.plant_inspections},
|
||||
{ endpoint: 'map/offtaker', to:"offtaker", from: db.offtakers},
|
||||
{ endpoint: 'distribusi/panen', to:"panen", from: db.distributions},
|
||||
{ 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'},
|
||||
// { endpoint: 'map/validator', to:"validator", from: db.validators}
|
||||
{ from: db.villages, to: "desa-kelurahan", endpoint: "master/desa-kelurahan" },
|
||||
{ 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 = {
|
||||
pending: "Menunggu Verifikasi",
|
||||
verified: "Terverifikasi",
|
||||
rejected: "Ditolak"
|
||||
pending: "W",
|
||||
verified: "V",
|
||||
rejected: "X"
|
||||
};
|
||||
|
||||
export default function FarmerCard({ farmer }) {
|
||||
console.log("Petani : ", farmer);
|
||||
return (
|
||||
<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="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 ? (
|
||||
<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">
|
||||
{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 items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 truncate">{farmer.full_name}</h3>
|
||||
<p className="text-sm text-slate-500">NIK: {farmer.nik}</p>
|
||||
<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?.kk}</p>
|
||||
</div>
|
||||
<Badge className={`${statusColors[farmer.verification_status]} border font-medium text-xs`}>
|
||||
{statusLabels[farmer.verification_status]}
|
||||
<Badge className={`${statusColors[farmer.user.email_verified_at !== null || farmer.user.email_verified_at !== undefined ? 'verified' : 'pending']} border font-medium text-xs`}>
|
||||
{statusLabels[farmer.user.email_verified_at !== null || farmer.user.email_verified_at !== undefined ? 'verified' : 'pending']}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-sm text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-slate-400" />
|
||||
<span className="truncate">{farmer.village}, {farmer.district}</span>
|
||||
<span className="truncate">{farmer.alamat}</span>
|
||||
</div>
|
||||
{farmer.farmer_group && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -53,10 +54,10 @@ export default function FarmerCard({ farmer }) {
|
||||
<span className="truncate">{farmer.farmer_group}</span>
|
||||
</div>
|
||||
)}
|
||||
{farmer.phone && (
|
||||
{farmer.telepon && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-slate-400" />
|
||||
<span>{farmer.phone}</span>
|
||||
<span>{farmer?.telepon}</span>
|
||||
</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
+187
-183
@@ -3,146 +3,52 @@ import { MapContainer, TileLayer, Marker, Polygon, useMap, Circle } from "react-
|
||||
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 { Trash2, Save, Navigation, RotateCcw, Zap, ZapOff, Map as MapIcon, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import L from "leaflet";
|
||||
import { Geolocation } from '@capacitor/geolocation';
|
||||
|
||||
// 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",
|
||||
// Konfigurasi Icon Marker agar muncul di Android
|
||||
const customMarkerIcon = new L.Icon({
|
||||
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",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
|
||||
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 }) {
|
||||
// Komponen Pengatur Peta (PENTING: Memperbaiki masalah layar putih)
|
||||
function MapController({ center, isTracking }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (center) {
|
||||
// Zoom 20 untuk melihat detail batas lahan lebih dekat
|
||||
map.flyTo(center, zoom || 20, { duration: 1.5 });
|
||||
// Memaksa Leaflet menghitung ulang ukuran container setelah render pertama
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
}, 500);
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
if (center && isTracking) {
|
||||
map.setView(center, 19, { animate: true });
|
||||
}
|
||||
}, [center, map, zoom]);
|
||||
}, [center, isTracking, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function GPSLandMapper({ existingPolygon, onSave, readOnly = false }) {
|
||||
export default function GPSLandMapper({ 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 [isTracking, setIsTracking] = useState(false);
|
||||
const [positionBuffer, setPositionBuffer] = useState([]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (points.length < 3) {
|
||||
@@ -157,83 +63,181 @@ export default function GPSLandMapper({ existingPolygon, onSave, readOnly = fals
|
||||
});
|
||||
};
|
||||
|
||||
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]
|
||||
});
|
||||
// Hitung Luas
|
||||
const area = useMemo(() => {
|
||||
if (points.length < 3) return 0;
|
||||
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 (
|
||||
<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>
|
||||
<Card className="w-full border-0 shadow-none bg-background flex flex-col h-[92vh]">
|
||||
|
||||
{/* SECTION 1: KONTROL UTAMA (Selalu Ada) */}
|
||||
<div className="p-4 space-y-3 bg-white z-[2000]">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${isTracking ? 'bg-green-500 animate-pulse' : 'bg-slate-300'}`} />
|
||||
<span className="text-xs font-bold text-slate-600 uppercase tracking-wide">
|
||||
{isTracking ? `Akurasi: ±${gpsAccuracy?.toFixed(1)}m` : "GPS Nonaktif"}
|
||||
</span>
|
||||
</div>
|
||||
{currentLocation && <Badge variant="outline">{currentLocation[0].toFixed(5)}, {currentLocation[1].toFixed(5)}</Badge>}
|
||||
{currentLocation && <Badge variant="outline" className="text-[10px] opacity-70">Sinyal Terkunci</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}
|
||||
<div className="flex gap-2">
|
||||
{!isTracking ? (
|
||||
<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 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]
|
||||
})} />
|
||||
<Button onClick={addPoint} className="flex-1 h-12 bg-emerald-600 hover:bg-emerald-700 text-white shadow-md font-bold">
|
||||
<Navigation className="mr-2 h-4 w-4" /> Tandai Sudut ({points.length})
|
||||
</Button>
|
||||
<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" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 }} />}
|
||||
{/* SECTION 2: AREA INFORMASI LUAS & SIMPAN (Muncul jika titik >= 3) */}
|
||||
{points.length >= 3 && (
|
||||
<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 && (
|
||||
<Button
|
||||
onClick={() => onSave({ points, area })}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg px-6 h-11 rounded-xl font-bold"
|
||||
>
|
||||
<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>
|
||||
|
||||
{!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>
|
||||
{/* 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>
|
||||
|
||||
Regular → Executable
+18
-4
@@ -4,6 +4,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { MapPin, Maximize2, TreePine, ChevronRight } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { entity } from "@/api/entities";
|
||||
import { OfflineService } from "../common/offlineStorage";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const validationColors = {
|
||||
valid: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
@@ -27,15 +30,26 @@ const statusColors = {
|
||||
};
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<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>}
|
||||
</div>
|
||||
<Badge className={`${validationColors[land.validation_status]} border font-medium text-xs`}>
|
||||
{validationLabels[land.validation_status]}
|
||||
<Badge className={`bg-emerald-600 border font-medium text-xs`}> Lahan
|
||||
</Badge>
|
||||
</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 gap-2 text-sm text-slate-500">
|
||||
<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>
|
||||
<Link to={createPageUrl("LandDetail") + `?id=${land.id}`}>
|
||||
<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