major file change

This commit is contained in:
2026-04-16 14:22:46 +07:00
parent c1e8f132c4
commit 11aa18c46c
190 changed files with 1651 additions and 625 deletions
+5
View File
@@ -0,0 +1,5 @@
[*]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
Regular → Executable
View File
View File
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
Regular → Executable
+1 -1
View File
@@ -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
View File
Generated Regular → Executable
+26
View File
@@ -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
View File
@@ -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
View File
@@ -11,6 +11,15 @@
}, },
{ {
"path": "../pertamina-point" "path": "../pertamina-point"
},
{
"path": "../dev/wayfinder-project"
},
{
"path": "../own-cloud"
},
{
"path": "../skhp"
} }
], ],
"settings": {} "settings": {}
Regular → Executable
View File
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+17 -16
View File
@@ -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
View File
@@ -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
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
+2 -3
View File
@@ -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);
View File
View File
+91 -25
View File
@@ -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"},
View File
View File
View File
View File
View File
View File
View File
View File
+12 -11
View File
@@ -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>
View File
View File
View File
+242
View File
@@ -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='&copy; 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>
);
}
View File
+186 -182
View File
@@ -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='&copy; 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>
+18 -4
View File
@@ -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">
View File
Regular → Executable
View File
View File
View File
View File
View File
Regular → Executable
View File
View File
Regular → Executable
View File

Some files were not shown because too many files have changed in this diff Show More