This commit is contained in:
2026-05-05 17:10:25 +07:00
parent ab5ff41c15
commit d45ab9df52
37 changed files with 2435 additions and 576 deletions
+1
View File
@@ -107,6 +107,7 @@
"globals": "^15.14.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"terser": "^5.46.2",
"typescript": "^5.8.2",
"vite": "^6.1.0"
}
+6
View File
@@ -20,6 +20,12 @@
},
{
"path": "../skhp"
},
{
"path": "../dev/clipflow"
},
{
"path": "../dev/front-clipflow"
}
],
"settings": {}
+252
View File
@@ -0,0 +1,252 @@
import { Link, useLocation } from "react-router-dom";
import { createPageUrl } from "@/utils";
import {
LayoutDashboard, Users, Map, TreePine,
CloudOff, Menu, X, ChevronRight, Shield, Wifi, WifiOff, RefreshCw, LogOut
} from "lucide-react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { OfflineService, isOnline as checkIsOnline } from "@/components/common/offlineStorage";
import {useAuth} from '@/lib/AuthContext';
function SyncIndicator() {
const [online, setOnline] = useState(checkIsOnline());
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
let mounted = true;
const updateStatus = async () => {
const isConnected = await checkInternet();
const count = await OfflineService.getPendingCount();
if (mounted) {
setOnline(isConnected);
setPendingCount(count);
}
};
updateStatus();
// Event listener untuk perubahan koneksi
const handleStatusChange = () => updateStatus();
window.addEventListener("online", handleStatusChange);
window.addEventListener("offline", handleStatusChange);
const interval = setInterval(updateStatus, 3000); // Polling setiap 3 detik
return () => {
mounted = false;
window.removeEventListener("online", handleStatusChange);
window.removeEventListener("offline", handleStatusChange);
clearInterval(interval);
};
}, []);
const isSyncPending = online && pendingCount > 0;
return (
<div
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl transition-colors",
!online && "bg-amber-50",
online && isSyncPending && "bg-yellow-50",
online && !isSyncPending && "bg-emerald-50"
)}
>
{!online && <WifiOff className="w-5 h-5 text-amber-600" />}
{online && isSyncPending && <RefreshCw className="w-5 h-5 text-yellow-600 animate-spin" />}
{online && !isSyncPending && <Wifi className="w-5 h-5 text-emerald-600" />}
<div className="flex-1 min-w-0">
<p className={cn("text-sm font-medium", !online ? "text-amber-700" : isSyncPending ? "text-yellow-700" : "text-emerald-700")}>
{!online ? "Offline" : isSyncPending ? "Online (Sinkronisasi...)" : "Online"}
</p>
<p className="text-xs truncate opacity-80">
{!online ? "Data disimpan lokal" : isSyncPending ? `${pendingCount} data diproses` : "Data sinkron"}
</p>
</div>
</div>
);
}
// async function checkInternet() {
// if (!navigator.onLine) return false;
// try {
// const controller = new AbortController();
// setTimeout(() => controller.abort(), 3000);
// const res = await fetch("/ping", {
// method: "HEAD",
// cache: "no-store",
// signal: controller.signal,
// });
// return res.ok;
// } catch {
// return false;
// }
// }
async function checkInternet() {
if (!navigator.onLine) return false;
try {
const controller = new AbortController();
setTimeout(() => controller.abort(), 3000);
await fetch("https://www.google.com/favicon.ico", {
method: "HEAD",
mode: "no-cors",
cache: "no-store",
signal: controller.signal,
});
return true;
} catch {
return false;
}
}
const navigation = [
{ 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 LayoutCopy({ children, currentPageName }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
const { user, logout } = useAuth();
// if (!user) {
// return <div className="flex h-screen items-center justify-center">Memuat Sesi...</div>;
// }
const filteredNavigation = navigation.filter((item) => {
if (!user?.role) return false;
return item.roles.includes(user.role);
});
const isActivePage = (pageName) => {
return currentPageName === pageName || location.pathname.includes(pageName.toLowerCase());
};
return (
<div className="min-h-screen bg-slate-50">
{/* Mobile Header */}
<div className="lg:hidden fixed top-0 left-0 right-0 pt-safe h-[6rem] bg-white border-b border-slate-200 z-50 flex items-center justify-between px-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => setSidebarOpen(true)}>
<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 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>
</div>
</div>
{/* Mobile Sidebar Overlay */}
{sidebarOpen && (
<div
className="lg:hidden fixed inset-0 bg-black/50 z-50"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside className={cn(
"fixed top-0 left-0 pt-safe h-full w-64 bg-white border-r border-slate-200 z-50 transition-transform duration-300",
"lg:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}>
<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 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>
<p className="text-xs text-slate-500">Monitoring Pertanian</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<X className="w-5 h-5" />
</Button>
</div>
<nav className="space-y-1">
{filteredNavigation.filter((n) => n.roles.includes(user.role)).map((item) => {
const isActive = isActivePage(item.page);
const isVisible = item.roles;
return (
<Link
key={item.page}
to={createPageUrl(item.page)}
onClick={() => setSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group",
isActive
? "bg-emerald-50 text-emerald-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
)}
>
<item.icon className={cn(
"w-5 h-5 transition-colors",
isActive ? "text-emerald-600" : "text-slate-400 group-hover:text-slate-600"
)} />
<span className="font-medium">{item.name}</span>
{isActive && (
<ChevronRight className="w-4 h-4 ml-auto text-emerald-600" />
)}
</Link>
);
})}
</nav>
</div>
{/* Sync Status */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-100">
<div className="p-4 space-y-2">
{/* Tombol Logout */}
<Button
variant="ghost"
onClick={() => {
logout();
}}
className="w-full flex items-center justify-start gap-3 px-4 py-3 text-slate-600 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all duration-200 group"
>
<LogOut className="w-5 h-5 text-slate-400 group-hover:text-red-500" />
<span className="font-medium">Keluar Sesi</span>
</Button>
{/* Indikator Online */}
<SyncIndicator />
</div>
</div>
</aside>
{/* Main Content */}
<main className="lg:ml-64 pt-16 lg:pt-0 min-h-screen">
{children}
</main>
</div>
);
}
+190 -92
View File
@@ -1,19 +1,66 @@
import { moodConfig } from "@/utils/moodColor";
import { Link, useLocation } from "react-router-dom";
import { createPageUrl } from "@/utils";
import {
LayoutDashboard, Users, Map, TreePine,
CloudOff, Menu, X, ChevronRight, Shield, Wifi, WifiOff, RefreshCw, LogOut
CloudOff, Menu, X, ChevronRight, Shield, Wifi, WifiOff, RefreshCw, LogOut, MessageSquare, Globe, BookOpenText
} from "lucide-react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { OfflineService, isOnline as checkIsOnline } from "@/components/common/offlineStorage";
import {useAuth} from '@/lib/AuthContext';
import SmartPopup from '@/components/ui/smartpopup';
function SyncIndicator() {
async function checkInternet() {
if (!navigator.onLine) return false;
try {
const controller = new AbortController();
setTimeout(() => controller.abort(), 3000);
await fetch("https://www.google.com/favicon.ico", {
method: "HEAD",
mode: "no-cors",
cache: "no-store",
signal: controller.signal,
});
return true;
} catch {
return false;
}
}
const wikiConfig = JSON.parse(import.meta.env.VITE_MEDIA_WIKI_URL || "[]");
const RawNavigation = [
{ 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', 'offtaker'] },
// { name: "Wiki", url: "https://wiki.probindo.com",icon: BookOpenText, roles: ['super_admin','fasilitator', 'petani', 'offtaker'] },
];
const navigation = [
...RawNavigation,
...wikiConfig.filter(item => item.active).map(item => ({
name: item.name,
url: item.url,
icon: BookOpenText,
roles: ['super_admin', 'fasilitator', 'petani', 'offtaker']
}))
]
console.log("Final Navigation:", navigation, "ENV Config:", wikiConfig);
function SyncIndicator(colorMood = "rose") {
const [online, setOnline] = useState(checkIsOnline());
const [pendingCount, setPendingCount] = useState(0);
const currentTheme = moodConfig[colorMood] || moodConfig.emerald;
useEffect(() => {
let mounted = true;
@@ -49,85 +96,42 @@ function SyncIndicator() {
return (
<div
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl transition-colors",
!online && "bg-amber-50",
online && isSyncPending && "bg-yellow-50",
online && !isSyncPending && "bg-emerald-50"
"flex items-center gap-3 px-4 py-3 rounded-xl transition-colors"
)}
>
{!online && <WifiOff className="w-5 h-5 text-amber-600" />}
{online && isSyncPending && <RefreshCw className="w-5 h-5 text-yellow-600 animate-spin" />}
{online && !isSyncPending && <Wifi className="w-5 h-5 text-emerald-600" />}
{online && !isSyncPending && <Wifi className={cn("w-5 h-5", currentTheme.accentText)} />}
<div className="flex-1 min-w-0">
<p className={cn("text-sm font-medium", !online ? "text-amber-700" : isSyncPending ? "text-yellow-700" : "text-emerald-700")}>
<p className={cn("text-sm font-medium", !online ? "text-amber-700" : isSyncPending ? "text-yellow-700" : currentTheme.accentText)}>
{!online ? "Offline" : isSyncPending ? "Online (Sinkronisasi...)" : "Online"}
</p>
<p className="text-xs truncate opacity-80">
<p className={cn("text-xs truncate opacity-80", currentTheme.accentText)}>
{!online ? "Data disimpan lokal" : isSyncPending ? `${pendingCount} data diproses` : "Data sinkron"}
</p>
</div>
</div>
);
}
// async function checkInternet() {
// if (!navigator.onLine) return false;
// try {
// const controller = new AbortController();
// setTimeout(() => controller.abort(), 3000);
// const res = await fetch("/ping", {
// method: "HEAD",
// cache: "no-store",
// signal: controller.signal,
// });
// return res.ok;
// } catch {
// return false;
// }
// }
async function checkInternet() {
if (!navigator.onLine) return false;
try {
const controller = new AbortController();
setTimeout(() => controller.abort(), 3000);
await fetch("https://www.google.com/favicon.ico", {
method: "HEAD",
mode: "no-cors",
cache: "no-store",
signal: controller.signal,
});
return true;
} catch {
return false;
}
}
const navigation = [
{ 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 }) {
export default function Layout({ children, currentPageName, colorMood = "emerald" }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
const { user, logout } = useAuth();
// if (!user) {
// return <div className="flex h-screen items-center justify-center">Memuat Sesi...</div>;
// }
// Ambil konfigurasi warna berdasarkan mood yang dipilih
const currentTheme = moodConfig[colorMood] || moodConfig.emerald;
// MEssage
const [showMessages, setShowMessages] = useState(false);
const [messages] = useState([
{ id: 1, title: "Pelatihan", description: "Pelatihan Bibit Kemiri pada tanggal: 24 Apr 2026, agenda simulasi penanaman yang baik, mengidentifikasikan mondisi tanah", date: "24 Apr 2026", read: false },
]);
const unreadCount = messages.filter(m => !m.read).length;
const filteredNavigation = navigation.filter((item) => {
if (!user?.role) return false;
@@ -135,13 +139,34 @@ export default function Layout({ children, currentPageName }) {
});
const isActivePage = (pageName) => {
if(!currentPageName || !pageName) return false;
return currentPageName === pageName || location.pathname.includes(pageName.toLowerCase());
};
const themeHex = {
purple: "#7c3aed",
rose: "#f2859d",
emerald: "#10b981",
blue: "#3b82f6"
};
// const themeRGB = {
// purple: "124, 58, 237",
// rose: "242, 133, 157",
// emerald: "16, 185, 129",
// blue: "59, 130, 246"
// };
return (
<div className="min-h-screen bg-slate-50">
{/* Mobile Header */}
<div className="lg:hidden fixed top-0 left-0 right-0 pt-safe h-[6rem] bg-white border-b border-slate-200 z-50 flex items-center justify-between px-4">
<div
className={cn("min-h-screen transition-colors duration-500", currentTheme.contentBg, "[&_button]:bg-[var(--primary-app)] [&_button]:text-white hover:[&_button]:opacity-90")}
style={{
'--primary-app': themeHex[colorMood] || themeHex.emerald,
'--primary-soft': colorMood === 'purple' ? '#f5f3ff' : '#f0fdf4'
}}
>
{/* ... bagian header mobile ... */}
<div className={cn("lg:hidden fixed top-0 left-0 right-0 pt-safe min-h-[4rem] border-slate-200 z-50 flex items-center justify-between px-4",currentTheme.sidebarBg)}>
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => setSidebarOpen(true)}>
<Menu className="w-6 h-6" />
@@ -154,6 +179,63 @@ export default function Layout({ children, currentPageName }) {
<span className="font-bold text-slate-900">AgroMonev</span>
</div>
</div>
{ user.role === 'petani' &&(
<div className="relative">
<button
onClick={() => setShowMessages(!showMessages)}
className="relative p-2 round-full hover:bg-slate-200 ransition-color rounded-2xl" >
<MessageSquare className={cn("w-6 h-6 text-white", currentTheme.navTextHover)} />
{ unreadCount > 0 && (
<span className="absolute top-1.5 right-1.5 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500 border-2 border-white"></span>
</span>
)}
</button>
{showMessages && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMessages(false)} />
<div className="absolute right-0 mt-2 w-80 bg-white rounded-2xl shadow-xl border border-slate-100 z-50 overflow-hidden">
<div className="p-4 border-b border-slate-50 flex justify-between items-center">
<h3 className="font-bold text-slate-800">Pesan Terakhir</h3>
<span className="text-xs text-blue-600 font-medium cursor-pointer">Tandai dibaca</span>
</div>
<div className="max-h-[350px] overflow-y-auto">
{messages.length > 0 ? (
messages.map((msg) => (
<div
key={msg.id}
className={cn(
"p-4 border-b border-slate-50 hover:bg-slate-50 transition-colors cursor-pointer",
!msg.read && "bg-blue-50"
)}
>
<div className="flex justify-between items-start mb-1">
<h4 className="text-sm font-semibold text-slate-900">{msg.title}</h4>
<span className="text-[10px] text-slate-500 whitespace-nowrap ml-2">{msg.date}</span>
</div>
<p className="text-xs text-slate-600 line-clamp-2 leading-relaxed">
{msg.description}
</p>
</div>
))
) : (
<div className="p-8 text-center text-slate-400 text-sm">
Tidak ada pesan baru
</div>
)}
</div>
<button className="w-full py-3 text-center text-xs font-medium text-slate-500 hover:text-slate-800 bg-slate-50/50">
Lihat Semua Pesan
</button>
</div>
</>
)}
</div>
)}
</div>
{/* Mobile Sidebar Overlay */}
@@ -166,36 +248,53 @@ export default function Layout({ children, currentPageName }) {
{/* Sidebar */}
<aside className={cn(
"fixed top-0 left-0 pt-safe h-full w-64 bg-white border-r border-slate-200 z-50 transition-transform duration-300",
"fixed top-0 left-0 pt-safe h-full w-64 z-50 border-r transition-all duration-300",
"lg:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
sidebarOpen ? "translate-x-0" : "-translate-x-full",
currentTheme.sidebarBg,
currentTheme.borderSide
)}>
<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 flex items-center justify-center shadow-lg shadow-emerald-500/25">
{/* <TreePine className="w-6 h-6 text-white" /> */}
<img src="/logo1.png" alt="" />
{/* SHADOW DYNAMIC */}
<div className={cn(
"w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center shadow-lg transition-shadow",
currentTheme.shadow
)}>
<img src="/logo1.png" alt="Logo" />
</div>
<div>
<h1 className="font-bold text-slate-900">AgroMonev</h1>
<p className="text-xs text-slate-500">Monitoring Pertanian</p>
<p className={cn("text-xs text-slate-500", currentTheme.navText)}>Monitoring Pertanian</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<X className="w-5 h-5" />
</Button>
{/* ... tombol close mobile ... */}
</div>
<nav className="space-y-1">
{filteredNavigation.filter((n) => n.roles.includes(user.role)).map((item) => {
{filteredNavigation.map((item) => {
const isActive = isActivePage(item.page);
const isVisible = item.roles;
if (item.url) {
return (
<a
key={item.name}
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => setSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group",
currentTheme.navText,
currentTheme.navTextHover
)}
>
<item.icon className="w-5 h-5 opacity-50 group-hover:opacity-100" />
<span className="font-medium">{item.name}</span>
</a>
);
}
return (
<Link
key={item.page}
@@ -204,25 +303,21 @@ export default function Layout({ children, currentPageName }) {
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group",
isActive
? "bg-emerald-50 text-emerald-700"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
? `${currentTheme.bgActive} ${currentTheme.textActive}`
: `${currentTheme.navText} ${currentTheme.navTextHover}` // Perubahan di sini
)}
>
<item.icon className={cn(
"w-5 h-5 transition-colors",
isActive ? "text-emerald-600" : "text-slate-400 group-hover:text-slate-600"
isActive ? currentTheme.iconActive : "opacity-50 group-hover:opacity-100"
)} />
<span className="font-medium">{item.name}</span>
{isActive && (
<ChevronRight className="w-4 h-4 ml-auto text-emerald-600" />
)}
</Link>
);
})}
</nav>
</div>
{/* Sync Status */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-100">
<div className="p-4 space-y-2">
{/* Tombol Logout */}
@@ -233,8 +328,8 @@ export default function Layout({ children, currentPageName }) {
}}
className="w-full flex items-center justify-start gap-3 px-4 py-3 text-slate-600 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all duration-200 group"
>
<LogOut className="w-5 h-5 text-slate-400 group-hover:text-red-500" />
<span className="font-medium">Keluar Sesi</span>
<LogOut className={cn("w-5 h-5 text-red-500 group-hover:text-red-500")} />
<span className={cn("font-medium text-red-500")}>Keluar Sesi</span>
</Button>
{/* Indikator Online */}
@@ -243,10 +338,13 @@ export default function Layout({ children, currentPageName }) {
</div>
</aside>
{/* Main Content */}
<main className="lg:ml-64 pt-16 lg:pt-0 min-h-screen">
<main className={cn(
"mt-8 lg:ml-64 pt-16 lg:pt-0 min-h-screen transition-all",
currentTheme.contentBg // Memastikan area konten mengikuti mood
)}>
{children}
</main>
<SmartPopup/>
</div>
);
}
+6 -3
View File
@@ -15,13 +15,16 @@ export function useSyncManager() {
setSyncing(true);
try {
const hasPending = await OfflineService.hasPendingData();
// checking if has pending data
if (hasPending) {
await OfflineService.syncAllPending();
toast.success("Sinkronisasi data berhasil");
} else {
}
// othewise resync all data from local to server and server to local
else {
// await OfflineService.downloadFromServer();
await OfflineService.syncDataFromServer();
// await OfflineService.syncDataFromServer();
await OfflineService.reconcileAndPush();
}
} catch (error) {
console.error('Sync process failed:', error);
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import { useTheme } from './ThemeContext';
const ThemeButton = ({ children, onClick }) => {
const { theme } = useTheme();
const buttonStyle = {
backgroundColor: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#000' : '#fff',
border: '1px solid #ccc',
padding: '10px 20px',
cursor: 'pointer',
};
return (
<button style={buttonStyle} onClick={onClick}>
{children}
</button>
);
};
export default ThemeButton;
+19
View File
@@ -0,0 +1,19 @@
import React, { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light'); // 'light' atau 'dark'
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);
+82 -11
View File
@@ -93,6 +93,7 @@ export const OfflineService = {
db.plants.where('sync_status').equals('pending').count(),
db.harvests.where('sync_status').equals('pending').count(),
db.plant_inspections.where('sync_status').equals('pending').count(),
db.land_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(),
@@ -134,7 +135,8 @@ export const OfflineService = {
{ 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.plant_inspections, to:"inspeksi-tanaman", endpoint: 'map/inspeksi/tanaman'},
{ from: db.land_inspections, to:"inspeksi-lahan", endpoint: 'map/inspeksi/lahan'},
{ 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" },
@@ -146,13 +148,13 @@ export const OfflineService = {
for (const config of syncConfigs) {
try {
console.log(`Syncing table: ${config.from.name}...`);
// getting data from server
const response = await axios.get(`${baseURL}/api/${config.endpoint}`, {
headers: { Authorization: `Bearer ${token}` }
});
console.log(response, "Res")
// check respond status
if (response.status !== 200) throw new Error(`HTTP error! status: ${response.status}`);
// take respond data
const result = await response;
const dataToSave = config.to ? result[config.to] : (result.data || result.data.data || []);
@@ -196,7 +198,8 @@ export const OfflineService = {
{ endpoint: 'map/lahan', table: db.lands },
{ endpoint: 'map/tanaman', table: db.plants },
{ endpoint: 'map/panen', table: db.harvests},
{ endpoint: 'map/inspeksi', table: db.plant_inspections},
{ endpoint: 'map/inspeksi/tanaman', table: db.plant_inspections},
{ endpoint: 'map/inspeksi/lahan', table: db.land_inspections},
{ endpoint: 'map/offtaker', table: db.offtakers},
{ endpoint: 'distribusi/panen', table: db.distributions},
// { endpoint: 'map/validator', table: db.validators}
@@ -255,11 +258,12 @@ export const OfflineService = {
},
syncAllPending: async () => {
const tables = [
{ from: db.farmers, to:"profile", endpoint: 'auth/profile/1' },
{ from: db.farmers, to:"profile", endpoint: 'auth/profile' },
{ 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.plant_inspections, to:"inspeksi-tanaman", endpoint: 'map/inspeksi/tanaman'},
{ from: db.land_inspections, to:"inspeksi-lahan", endpoint: 'map/inspeksi/lahan'},
{ from: db.offtakers, to:"offtaker", endpoint: 'map/offtaker'},
{ from: db.distributions, to:"panen", endpoint: 'distribusi/panen'},
// { endpoint: 'map/validator', to:"validator", from: db.validators}
@@ -271,25 +275,40 @@ export const OfflineService = {
const token = localStorage.getItem('access_token');
const baseURL = import.meta.env.VITE_BASE44_API_URL.replace(/\/+$/, "");
let hasChanged = false;
for (const item of tables) {
//get pending data
const items = await item.from.where('sync_status').equals('pending').toArray();
for (const record of items) {
try {
// data preparation
const { sync_status, ...dataToPush } = record;
//convert the data
if(item.to === 'lahan' && typeof dataToPush.path === 'string' ){
const pathToArray = JSON.parse(dataToPush.path);
dataToPush.path = pathToArray
console.log("Data lahan => ", dataToPush);
}
if (dataToPush.luas_lahan) {
dataToPush.luas_lahan = parseFloat(parseFloat(dataToPush.luas_lahan).toFixed(2));
}
//try to push
const response = await axios.post(`${baseURL}/api/${item.endpoint}`, dataToPush, {
headers: { Authorization: `Bearer ${token}` }
});
//check respond from server
if (response.status === 200 || response.status === 201) {
await item.table.delete(record.id);
// server id
const newId = response.data?.data?.id || record.id;
await item.from.update(record.id, {
sync_status : 'synced',
id : newId
})
// await item.table.delete(record.id);
hasChanged = true;
}
} catch (e) {
@@ -305,6 +324,58 @@ export const OfflineService = {
await OfflineService.downloadFromServer();
}
},
reconcileAndPush: async () => {
const token = localStorage.getItem('access_token');
const baseURL = import.meta.env.VITE_BASE44_API_URL.replace(/\/+$/, "");
const tables = [
{ 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-tanaman", endpoint: 'map/inspeksi/tanaman'},
{ from: db.land_inspections, to:"inspeksi-lahan", endpoint: 'map/inspeksi/lahan'},
{ 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"},
{ from: db.regencies, to: "kabupaten-kota", endpoint: "master/kabupaten-kota"},
{ from: db.provinces, to: "provinsi", endpoint: "master/provinsi"},
];
for (const item of tables) {
try {
// get all data from server
const response = await axios.get(`${baseURL}/api/${item.endpoint}`, {
headers: { Authorization: `Bearer ${token}` }
});
const serverData = response.data?.data || [];
const serverIds = new Set(serverData.map(d => d.id));
// check data doesnt exis on server but exist locally by id
const localSynced = await item.from.where('sync_status').equals('synced').toArray();
if(item.to == 'tanaman'){
console.log(localSynced, "Tanaman ");
}
const missingInServer = localSynced.filter(local => !serverIds.has(local.id));
if (missingInServer.length > 0) {
console.log(`Menemukan ${missingInServer.length} data hantu di ${item.from.name}, mengembalikan ke pending...`);
// returning data to pending
const idsToFix = missingInServer.map(d => d.id);
await item.from.where('id').anyOf(idsToFix).modify({
sync_status: 'pending'
});
}
} catch (err) {
console.error(`Gagal rekonsiliasi tabel ${item.from.name}:`, err);
}
}
// sync all pending when state change
await OfflineService.syncAllPending();
},
cacheMasterData: async (villages) => {
if (villages.length > 0) {
await db.master_villages.clear();
+21 -5
View File
@@ -4,6 +4,9 @@ import { Button } from "@/components/ui/button";
import { MapPin, Phone, Users, ChevronRight } from "lucide-react";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Switch } from "@/components/ui/switch";
import { entity } from "@/api/entities";
import { useMutation } from "@tanstack/react-query";
const statusColors = {
pending: "bg-amber-100 text-amber-700 border-amber-200",
@@ -18,7 +21,13 @@ const statusLabels = {
};
export default function FarmerCard({ farmer }) {
console.log("Petani : ", farmer);
const toggleActiveMutation = useMutation({
mutationFn: async ({ id, email_verified_at, farmer }) => await entity('auth','profile').update(id, { email_verified_at, ...farmer}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["validators"] });
toast.success("Status validator diperbarui");
}
});
return (
<Card className="border-0 shadow-sm hover:shadow-md transition-all p-5 bg-white group">
<div className="flex items-start gap-4">
@@ -27,7 +36,7 @@ export default function FarmerCard({ farmer }) {
<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"}
{farmer.full_name?.charAt(0)?.toUpperCase() || "N/A"}
</span>
)}
</div>
@@ -38,9 +47,16 @@ export default function FarmerCard({ farmer }) {
<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.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>
<Switch
checked={farmer?.user?.email_verified_at}
onCheckedChange={(checked) => {
const timestamp = checked ? new Date().toISOString() : null;
toggleActiveMutation.mutate({ id: farmer.id, email_verified_at: timestamp, farmer: farmer })
}}
/>
{/* <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">
+237 -28
View File
@@ -15,17 +15,32 @@ import { useQuery } from "@tanstack/react-query";
// ];
export default function FarmerForm({ initialData, onSubmit, isLoading }) {
const [locationData, setLocationData ] = useState({
provinces: [],
regencies: [],
districts: [],
villages: []
});
const [formData, setFormData] = useState(initialData || {
nik: "",
full_name: "",
phone: "",
ktp: "",
kk:"",
nama: "",
email:"",
desa_kelurahan_id:'',
telepon: "",
alamat: "",
farmer_group: "",
village: "",
district: "",
regency: "",
province: "",
verification_status: "pending",
sync_status: "pending"
sync_status: "pending",
kecamatan_id:"",
kabkota: "",
provinsi:"",
password:"",
password_confirmation: ""
});
const { data: rawProvinces } = useQuery({
@@ -71,6 +86,77 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
}
}, [provinces]);
useEffect(() => {
const autoFillLocation = async () => {
if(!formData.desa_kelurahan_id) return;
const selectedVillage = locationData.villages.find(data => formData.desa_kelurahan_id === data.id);
if(!selectedVillage) return;
console.log(selectedVillage, "Selected Village");
if (selectedVillage) {
const selectedDistrict = selectedVillage.kecamatan;
const selectedRegency = selectedDistrict?.kabupatenKota;
const selectedProvince = selectedRegency?.provinsi;
setLocationData((prev) => ({
...prev,
districts: selectedDistrict ? [selectedDistrict] : [],
regencies: selectedRegency ? [selectedRegency] : [],
provinces: selectedProvince ? [selectedProvince] : [],
}));
}
};
autoFillLocation();
}, [formData.desa_kelurahan_id, locationData.villages]);
useEffect(() => {
const prepareDataLocation = async () => {
// 1. Cek status koneksi
const isOnline = navigator.onLine;
try {
let data;
if (isOnline) {
console.log("Fetching from Server...");
const [v, d, r, p] = await Promise.all([
entity('master', 'desa-kelurahan').list(),
entity('master', 'kecamatan').list(),
entity('master', 'kabupaten-kota').list(),
entity('master', 'provinsi').list(),
]);
data = {
villages: v?.data?.data || v?.data || [],
districts: d?.data?.data || d?.data || [],
regencies: r?.data?.data || r?.data || [],
provinces: p?.data?.data || p?.data || []
};
} else {
// JIKA OFFLINE: Ambil dari Dexie / SQLite
console.log("Loading from Offline Storage...");
const [v, d, r, p] = await Promise.all([
OfflineService.getEntities('villages'),
OfflineService.getEntities('districts'),
OfflineService.getEntities('regencies'),
OfflineService.getEntities('provinces'),
]);
data = {
villages: v || [],
districts: d || [],
regencies: r || [],
provinces: p || []
};
}
setLocationData(data);
} catch (err) {
console.error("Gagal memuat data lokasi:", err);
}
};
prepareDataLocation();
}, []);
console.log(locationData);
return (
<form onSubmit={handleSubmit} className="space-y-6">
<Card className="border-0 shadow-sm">
@@ -81,13 +167,66 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* <span className="text-start text-black text-lg font-bold"> <h4>Data Pengguna</h4></span> */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="nik">NIK (Nomor Induk Kependudukan)</Label>
<Label htmlFor="district">E-mail</Label>
<Input
id="nik"
id="district"
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
placeholder="E-mail"
required
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nama">Nama Lengkap</Label>
<Input
id="nama"
value={formData.nama}
onChange={(e) => handleChange("nama", e.target.value)}
placeholder="Nama sesuai KTP"
required
className="h-11"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
className="pl-4"
value={formData.password}
onChange={(e) => handleChange("password", e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="nama">Confirm Password</Label>
<Input
id="password_confirmation"
type="password"
placeholder="••••••••"
className="pl-4"
value={formData.password_confirmation}
onChange={(e) => handleChange("password_confirmation", e.target.value)}
required
/>
</div>
</div>
<div className="w-full border-b-2 mb-2"></div>
{/* <span className="text-start text-black text-lg font-bold"> <h4>Data Petani</h4></span> */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ktp">NIK (Nomor Induk Kependudukan)</Label>
<Input
id="ktp"
value={formData.nik}
onChange={(e) => handleChange("nik", e.target.value)}
onChange={(e) => handleChange("ktp", e.target.value)}
placeholder="16 digit NIK"
maxLength={16}
required
@@ -95,6 +234,18 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="kk">NO. KK</Label>
<Input
id="kk"
value={formData.kk}
onChange={(e) => handleChange("kk", e.target.value)}
placeholder="nomor induk kartu keluarga"
maxLength={16}
required
className="h-11"
/>
</div>
{/* <div className="space-y-2">
<Label htmlFor="full_name">Nama Lengkap</Label>
<Input
id="full_name"
@@ -104,17 +255,31 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
required
className="h-11"
/>
</div> */}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex-1">
<Label htmlFor="telepon">Alamat</Label>
<textarea
name="alamat"
id="alamat"
placeholder="Masukkan alamat lengkap..."
rows={4}
className="w-full rounded-md border-2 p-3 text-white focus:ring-2 focus:ring-emerald-500"
value={formData.alamat}
onChange={(e) => handleChange("alamat", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Nomor Telepon</Label>
<Label htmlFor="telepon">Nomor Telepon</Label>
<div className="relative">
<Phone className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<Input
id="phone"
value={formData.phone}
onChange={(e) => handleChange("phone", e.target.value)}
id="telepon"
value={formData.telepon}
onChange={(e) => handleChange("telepon", e.target.value)}
placeholder="08xxxxxxxxxx"
className="h-11 pl-10"
/>
@@ -147,31 +312,51 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="province">Provinsi</Label>
<Select value={formData.province} onValueChange={(v) => handleChange("province", v)}>
<SelectTrigger className="h-11">
<SelectValue placeholder="Pilih provinsi" />
<Label htmlFor="desa_kelurahan_id">Desa/Kelurahan</Label>
{/* <Input
id="desa_kelurahan_id"
value={formData.desa_kelurahan_id}
onChange={(e) => handleChange("desa_kelurahan_id", e.target.value)}
placeholder="Nama desa/kelurahan"
required
className="h-11"
/> */}
<Select
name="desa_kelurahan_id"
value={formData?.desa_kelurahan_id?.toString()}
onValueChange={(e) => setFormData((prev) => ({
...prev,
desa_kelurahan_id: parseInt(e)
}))}
>
<SelectTrigger className="!bg-transparent !text-black font-bold text-blue-700"> {/* Berikan placeholder agar tidak kosong saat awal */}
<SelectValue placeholder="Pilih Desa/Kelurahan" />
</SelectTrigger>
<SelectContent>
{provinces.length > 0 ? (
provinces.map((p, idx) => (
<SelectItem key={p.id || idx} value={p.nama || p.name}>
{p.nama || p.name}
{locationData && locationData.villages.length > 0 ? (
locationData.villages.map((data) => (
<SelectItem
key={data?.id}
value={data?.id.toString()} // Ini String
>
{data.nama}
</SelectItem>
))
) : (
<SelectItem value="none" disabled>Data tidak tersedia</SelectItem>
<div className="p-2 text-sm text-muted-foreground">Memuat Data...</div>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="regency">Kabupaten/Kota</Label>
<Label htmlFor="district">Kecamatan</Label>
<Input
id="regency"
value={formData.regency}
onChange={(e) => handleChange("regency", e.target.value)}
placeholder="Nama kabupaten/kota"
id="kecamatan"
value={locationData.districts[0]?.nama || "-"}
onChange={(e) => handleChange("kecamatan_id", e.target.value)}
placeholder="Nama kecamatan"
required
className="h-11"
/>
@@ -179,6 +364,30 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="kabkota_id">Kabupaten/Kota</Label>
<Input
id="regency"
value={locationData.regencies[0]?.nama || "-"}
onChange={(e) => handleChange("kabkota_id", e.target.value)}
placeholder="Nama kabupaten/kota"
required
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="provinsi">Provinsi</Label>
<Input
id="provinsi"
value={locationData.provinces[0]?.nama || "-"}
onChange={(e) => handleChange("provinsi", e.target.value)}
placeholder="Nama Provinsi"
required
className="h-11"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* <div className="space-y-2">
<Label htmlFor="district">Kecamatan</Label>
<Input
id="district"
@@ -188,8 +397,8 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
required
className="h-11"
/>
</div>
<div className="space-y-2">
</div> */}
{/* <div className="space-y-2">
<Label htmlFor="village">Desa/Kelurahan</Label>
<Input
id="village"
@@ -199,7 +408,7 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
required
className="h-11"
/>
</div>
</div> */}
</div>
</CardContent>
</Card>
+11 -6
View File
@@ -5,8 +5,9 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2, Scale, Calendar as CalendarIcon, MapPin, TreePine } from "lucide-react";
export default function HarvestForm({ lands, plants, onSubmit, onCancel, isLoading }) {
export default function HarvestForm({ farmer ,lands, plants, onSubmit, onCancel, isLoading }) {
const [formData, setFormData] = useState({
user_id: 0,
land_id: "",
plant_id: "general", // "general" jika panen seluruh lahan, atau ID tanaman spesifik
commodity_name: "",
@@ -18,13 +19,17 @@ export default function HarvestForm({ lands, plants, onSubmit, onCancel, isLoadi
// Filter tanaman berdasarkan lahan yang dipilih
const filteredPlants = useMemo(() => {
if (!formData.land_id) return [];
return plants.filter(p => p.land_id === formData.land_id);
return plants.filter((p) => {
const plantLandId = p.lahan?.id || p.land_id;
return Number(plantLandId) === Number(formData.land_id);
});
}, [plants, formData.land_id]);
const handleSubmit = (e) => {
e.preventDefault();
if (!formData.land_id || !formData.weight_kg || !formData.commodity_name) return;
console.log("Submitting harvest data => ", formData);
onSubmit({
...formData,
weight_kg: parseFloat(formData.weight_kg),
@@ -47,7 +52,7 @@ export default function HarvestForm({ lands, plants, onSubmit, onCancel, isLoadi
</SelectTrigger>
<SelectContent>
{lands.map(land => (
<SelectItem key={land.id} value={land.id}>{land.name}</SelectItem>
<SelectItem key={land.id.toString()} value={land.id.toString()}>{land.name || land.nama || 'N/A'}</SelectItem>
))}
</SelectContent>
</Select>
@@ -76,8 +81,8 @@ export default function HarvestForm({ lands, plants, onSubmit, onCancel, isLoadi
<SelectContent>
<SelectItem value="general">Hasil Lahan Umum</SelectItem>
{filteredPlants.map(plant => (
<SelectItem key={plant.id} value={plant.id}>
{plant.commodity_name} (ID: {plant.id.slice(-4)})
<SelectItem key={plant.id.toString()} value={plant.id.toString()}>
{plant.nama || 'N/A'} (ID: {plant.id.toString().slice(-4)})
</SelectItem>
))}
</SelectContent>
+20 -7
View File
@@ -55,11 +55,21 @@ export default function GPSLandMapper({ onSave, readOnly = false }) {
toast.error("Minimal harus ada 3 titik");
return;
}
const centerLat = points.reduce((s, p) => s + p[0], 0) / points.length;
const centerLng = points.reduce((s, p) => s + p[1], 0) / points.length;
// 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
// });
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
polygon_coordinates: points.map(p => [p[1], p[0]]), // Format [lng, lat] untuk backend
center_lat: centerLat,
center_lng: centerLng,
area_hectares: area,
points: points // opsional jika ingin tetap menyimpan titik asli [lat, lng]
});
};
@@ -149,15 +159,15 @@ export default function GPSLandMapper({ onSave, readOnly = false }) {
<div className="flex gap-2">
{!isTracking ? (
<Button onClick={() => setIsTracking(true)} className="flex-1 h-12 bg-blue-600 shadow-sm font-bold">
<Button type="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 onClick={addPoint} className="flex-1 h-12 bg-emerald-600 hover:bg-emerald-700 text-white shadow-md font-bold">
<Button type="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">
<Button type="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>
</>
@@ -178,6 +188,7 @@ export default function GPSLandMapper({ onSave, readOnly = false }) {
{!readOnly && (
<Button
type="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"
>
@@ -225,6 +236,7 @@ export default function GPSLandMapper({ onSave, readOnly = false }) {
{points.length > 0 && (
<div className="absolute bottom-6 left-4 right-4 flex gap-2 z-[1000]">
<Button
type="button"
variant="secondary"
className="flex-1 bg-white/90 backdrop-blur shadow-md"
onClick={() => setPoints(points.slice(0, -1))}
@@ -232,6 +244,7 @@ export default function GPSLandMapper({ onSave, readOnly = false }) {
<RotateCcw className="w-4 h-4 mr-2" /> Batal
</Button>
<Button
type="button"
variant="destructive"
className="flex-1 shadow-md"
onClick={() => setPoints([])}
+6 -1
View File
@@ -29,7 +29,7 @@ const statusColors = {
government: "Tanah Negara"
};
export default function LandCard({ land, farmerName, plantCount = 0 }) {
export default function LandCard({ land, farmerName, plantCount = 0, lastInspection= null }) {
const [village, setVillage] = useState();
useEffect(() => {
@@ -90,6 +90,11 @@ export default function LandCard({ land, farmerName, plantCount = 0 }) {
<span className="text-xs text-slate-500">Status: {statusColors[land.land_status]}</span>
</div>
)}
{lastInspection && null !== lastInspection && (
<div className="mt-2 pt-2 border-t border-slate-100">
<span className="text-xs text-slate-500"><strong> Inspeksi terakhir: {new Date(lastInspection.created_at).toLocaleDateString()} | PH {lastInspection.ph} | Kadar Air {lastInspection.kadar_air} / {lastInspection.kelembapan}</strong></span>
</div>
)}
</Card>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { ClipboardCheck, MapPin, Leaf } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
// Changed to a standard arrow function export for consistency with your syntax
export const LandCardNew = ({ land, farmerName, plantCount, onInspect }) => {
return (
<Card className="border-0 shadow-sm hover:shadow-md transition-all p-5 bg-white group">
<div className="flex items-start justify-between">
{/* Left Side: Text Info */}
<div className="flex-1 pr-2">
<h3 className="font-bold text-lg text-slate-900 leading-tight">
{land.nama || land?.name || "Lahan Tanpa Nama"}
</h3>
{farmerName && (
<p className="text-sm text-slate-500 mt-1">{farmerName}</p>
)}
</div>
{/* Right Side: Button Area (Matching Image) */}
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="outline"
className="rounded-full px-3 py-1 border-emerald-200 bg-emerald-50/50 text-emerald-600 font-semibold text-xs"
>
Produktif
</Badge>
<Button
variant="outline"
size="icon"
className="h-10 w-10 rounded-xl border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300 shadow-sm transition-all"
onClick={(e) => {
e.stopPropagation();
if (onInspect) onInspect(land);
}}
>
<ClipboardCheck className="w-5 h-5 text-slate-700" />
</Button>
</div>
</div>
{/* Card Body Info */}
<div className="space-y-2 mt-5">
<div className="flex items-center text-sm text-slate-600">
<MapPin className="w-4 h-4 mr-2 text-slate-400" />
<span className="truncate">{land.lokasi || "Lokasi belum diatur"}</span>
</div>
<div className="flex items-center text-sm text-slate-600">
<Leaf className="w-4 h-4 mr-2 text-emerald-500" />
<span><strong className="text-slate-900 font-bold">{plantCount || 0}</strong> Tanaman</span>
</div>
</div>
</Card>
);
};
export default LandCardNew;
+252
View File
@@ -0,0 +1,252 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import base44 from "@/api/Client";
import { entity } from "@/api/entities";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
CheckCircle, AlertTriangle, Bug, Leaf, Cloud, Droplets,
Loader2, Camera, X
} from "lucide-react";
import { toast } from "sonner";
const healthOptions = [
{ value: "healthy", label: "Sehat", icon: CheckCircle, color: "text-emerald-600" },
{ value: "mild_issue", label: "Masalah Ringan", icon: AlertTriangle, color: "text-amber-500" },
{ value: "moderate_issue", label: "Masalah Sedang", icon: AlertTriangle, color: "text-orange-500" },
{ value: "severe_issue", label: "Masalah Berat", icon: AlertTriangle, color: "text-red-500" }
];
const productivityOptions = [
{ value: "productive", label: "Produktif" },
{ value: "less_productive", label: "Kurang Produktif" },
{ value: "not_productive", label: "Tidak Produktif" }
];
const issueOptions = [
{ value: "none", label: "Tidak Ada Masalah", icon: CheckCircle },
{ value: "pest", label: "Hama", icon: Bug },
{ value: "disease", label: "Penyakit", icon: Leaf },
{ value: "weather", label: "Cuaca Buruk", icon: Cloud },
{ value: "nutrient_deficiency", label: "Kekurangan Nutrisi", icon: Leaf },
{ value: "water_shortage", label: "Kekurangan Air", icon: Droplets },
{ value: "other", label: "Lainnya", icon: AlertTriangle }
];
export default function LandInspectionForm({ land,farmer_id, onClose, onSuccess }) {
const queryClient = useQueryClient();
const [form, setForm] = useState({
lahan_id: land.id,
petani_id: farmer_id,
jenis_masalah: "",
desk_masalah:"",
kadar_air: 0,
ph: 0,
kelembapan: 0,
temperatur: "",
kondosi_tanaman: "",
catatan: "",
foto_url: "",
});
const inspectionMutation = useMutation({
mutationFn: async (data) => {
console.log("Submitting inspection with data => ", data)
try{
const existingInspection = await entity('map', 'inspeksi/lahan').find({
petani_id: farmer_id,
lahan_id: land.id,
});
const payload = {
petani_id: farmer_id,
lahan_id: land.id,
jenis_masalah: "",
desk_masalah:"",
// kadar_air: data.kadar_air,
// ph: parseFloat(data.ph),
// kelembapan: data.kelempaban,
// temperatur: data.temperature,
// kondosi_tanaman: data.kondisi_tanaman,
// catatan: data.catatan,
// foto_url: data.foto_url,
// created_at: new Date().toISOString(),
...data
};
// const foundData = Array.isArray(existingInspection.data) ? existingInspection.data : Array.isArray(existingInspection?.data?.data) ? existingInspection.data.data : [];
// if (foundData && foundData.length > 0) {
// const idToUpdate = foundData[0].id;
// await entity('map', 'inspeksi/lahan').update(idToUpdate, payload);
// console.log("Data updated successfully");
// } else {
await entity('map', 'inspeksi/lahan').create(payload);
console.log("Data created successfully");
// }
}catch(e){
console.error("Insert failed : ", e)
}
const plantStatus = form.health_status === "severe_issue" ? "sick" :
form.health_status === "healthy" ? "alive" : "alive";
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["plants"] });
queryClient.invalidateQueries({ queryKey: ["farmer-plants"] });
toast.success("Inspeksi berhasil disimpan!");
onSuccess?.();
onClose?.();
}
});
console.log(land)
return (
<Card className="border-0 shadow-lg">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Inspeksi Lahan</CardTitle>
{/* <Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button> */}
</div>
<p className="text-sm text-slate-500">{land.name || land.nama || ''}</p>
</CardHeader>
<CardContent className="space-y-4">
{/* Health Status */}
{/* <div className="space-y-2">
<Label>Status Kesehatan *</Label>
<div className="grid grid-cols-2 gap-2">
{healthOptions.map(option => {
const Icon = option.icon;
return (
<Button
key={option.value}
type="button"
variant={form.health_status === option.value ? "default" : "outline"}
className={`justify-start h-auto py-3 ${form.health_status === option.value ? "bg-slate-800" : ""}`}
onClick={() => setForm(p => ({ ...p, health_status: option.value }))}
>
<Icon className={`w-4 h-4 mr-2 ${form.health_status === option.value ? "text-white" : option.color}`} />
{option.label}
</Button>
);
})}
</div>
</div> */}
{/* Issue Type */}
<div className="space-y-2">
<Label>Jenis Masalah</Label>
<Select value={form.issue_type} onValueChange={(v) => setForm(p => ({ ...p, issue_type: v }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{issueOptions.map(opt => {
const Icon = opt.icon;
return (
<SelectItem key={opt.value} value={opt.value}>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" />
{opt.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Issue Description */}
{form.issue_type !== "none" && (
<div className="space-y-2">
<Label>Deskripsi Masalah</Label>
<Textarea
value={form.issue_description}
onChange={(e) => setForm(p => ({ ...p, issue_description: e.target.value }))}
placeholder="Jelaskan kondisi masalah yang ditemukan..."
rows={3}
/>
</div>
)}
<div className="space-y-2">
<Label>Kadar Air</Label>
<input
value={form.kadar_air}
type="text"
onChange={(e) => setForm(p => ({ ...p, kadar_air: e.target.value }))}
placeholder="Kadar Air tanah"
className="bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs"
/>
</div>
<div className="space-y-2">
<Label>Kadar PH</Label>
<input
value={form.ph}
type="text"
onChange={(e) => setForm(p => ({ ...p, ph: e.target.value }))}
placeholder="Kadar Air tanah"
className="bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs"
/>
</div>
<div className="space-y-2">
<Label>Kadar Kelembapan</Label>
<input
value={form.kelembapan}
type="text"
onChange={(e) => setForm(p => ({ ...p, kelembapan: e.target.value }))}
placeholder="Kadar Air tanah"
className="bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs"
/>
</div>
<div className="space-y-2">
<Label>Kelembapan cuaca</Label>
<input
value={form.temperatur}
type="text"
onChange={(e) => setForm(p => ({ ...p, temperatur: e.target.value }))}
placeholder="Kadar Air tanah"
className="bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs"
/>
</div>
{/* Notes */}
<div className="space-y-2">
<Label>Catatan Tambahan</Label>
<Textarea
value={form.catatan}
onChange={(e) => setForm(p => ({ ...p, catatan: e.target.value }))}
placeholder="Catatan lainnya..."
rows={2}
/>
</div>
{/* Submit */}
<div className="flex gap-3 pt-2">
<Button variant="outline" className="flex-1" onClick={onClose}>
Batal
</Button>
<Button
className="flex-1 bg-emerald-600 hover:bg-emerald-700"
onClick={() => inspectionMutation.mutate(form)}
disabled={inspectionMutation.isPending}
>
{inspectionMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
)}
Simpan Inspeksi
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -37,8 +37,11 @@ const issueOptions = [
];
export default function PlantInspectionForm({ plant,farmer_id, onClose, onSuccess }) {
console.log("Rendering PlantInspectionForm with plant:", plant, "and farmer_id:", farmer_id);
const queryClient = useQueryClient();
const [form, setForm] = useState({
tanaman_id:plant?.id,
lahan_id:plant?.lahan?.id,
health_status: "healthy",
productivity_status: "productive",
issue_type: "none",
@@ -49,30 +52,30 @@ export default function PlantInspectionForm({ plant,farmer_id, onClose, onSucces
const inspectionMutation = useMutation({
mutationFn: async (data) => {
console.log("create inspection", data)
try{
const existingInspeksi = await entity('map', 'inspeksi').find({
const existingInspeksi = await entity('map', 'inspeksi/tanaman').find({
petani_id: farmer_id,
lahan_id: plant.lahan_id,
tanaman_id: plant.id
});
console.log(existingInspeksi.data.data, 'inspeksi');
const payload = {
tanaman_id: plant.id,
lahan_id: plant.lahan_id,
lahan_id: plant?.lahan?.id,
petani_id: farmer_id,
inspection_date: new Date().toISOString(),
...data
};
if (existingInspeksi.data.data && existingInspeksi.data.data.length > 0) {
const idToUpdate = existingInspeksi.data.data[0].id;
await entity('map', 'inspeksi').update(idToUpdate, payload);
await entity('map', 'inspeksi/tanaman').update(idToUpdate, payload);
console.log("Data updated successfully");
} else {
await entity('map', 'inspeksi').create(payload);
await entity('map', 'inspeksi/tanaman').create(payload);
console.log("Data created successfully");
}
// await entity('map', 'inspeksi').create({
// 'tanaman_id' : plant.id,
// "lahan_id" : plant.lahan_id,
+96
View File
@@ -0,0 +1,96 @@
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Bell } from 'lucide-react';
const SmartPopup = () => {
const [isOpen, setIsOpen] = useState(false);
// Konfigurasi (dalam milidetik)
const IDLE_THRESHOLD = 30 * 60 * 1000; // 30 Menit tidak aktif
const POPUP_DELAY = 10 * 1000; // Muncul setelah 10 detik aktif
const showPopup = useCallback(() => {
setIsOpen(true);
// Simpan timestamp kapan terakhir popup muncul
localStorage.setItem('last_popup_time', Date.now().toString());
}, []);
useEffect(() => {
const handleActivity = () => {
const now = Date.now();
const lastPopup = parseInt(localStorage.getItem('last_popup_time') || '0');
const lastActivity = parseInt(localStorage.getItem('last_activity_time') || '0');
// Update waktu aktivitas terakhir di storage
localStorage.setItem('last_activity_time', now.toString());
// LOGIKA: Munculkan popup jika:
// 1. Belum pernah muncul (lastPopup === 0)
// 2. ATAU User sudah tidak aktif melebihi threshold (Idle)
if (lastPopup === 0 || (now - lastActivity > IDLE_THRESHOLD)) {
// Beri jeda 10 detik sebelum muncul sesuai request awal
const timer = setTimeout(() => {
showPopup();
}, POPUP_DELAY);
return () => clearTimeout(timer);
}
};
// Deteksi aktivitas user (mouse, ketik, scroll, sentuh)
const activityEvents = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart'];
// Jalankan pengecekan saat pertama kali load
handleActivity();
activityEvents.forEach(event =>
window.addEventListener(event, handleActivity, { passive: true })
);
return () => {
activityEvents.forEach(event =>
window.removeEventListener(event, handleActivity)
);
};
}, [showPopup, IDLE_THRESHOLD, POPUP_DELAY]);
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[999] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="bg-white rounded-2xl shadow-2xl max-w-sm w-full p-8 relative"
>
<button
onClick={() => setIsOpen(false)}
className="absolute top-4 right-4 text-slate-400 hover:text-slate-600"
>
<X size={20} />
</button>
<div className="text-center">
<div className="w-16 h-16 bg-amber-100 text-amber-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Bell size={32} />
</div>
<h3 className="text-xl font-bold text-slate-900 mb-2">Selamat Datang Kembali!</h3>
<p className="text-slate-500 mb-6">
Kami mendeteksi aktivitas baru. Jangan lupa untuk memeriksa pembaruan data pertanian Anda hari ini.
</p>
<button
onClick={() => setIsOpen(false)}
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-semibold py-3 rounded-xl transition-colors"
>
Lanjutkan Aktivitas
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
};
export default SmartPopup;
+14
View File
@@ -84,6 +84,20 @@
body {
@apply bg-background text-foreground;
-webkit-user-select: none;
user-select: none;
}
input, textarea, [contenteditable="true"] {
-webkit-user-select: text !important;
user-select: text !important;
}
aside {
-webkit-overflow-scrolling: touch;
}
.sidebar-container {
-webkit-overflow-scrolling: touch;
}
html {
+1 -1
View File
@@ -79,7 +79,7 @@ export const PAGES = {
"Validators": {component: Validators, roles: ['super_admin']},
"ValidatorPortal": {component: ValidatorPortal, roles: ['super_admin']},
"Offtakers": {component: Offtakers, roles: ['super_admin']},
"OfftakerPortal": {component: OfftakerPortal, roles: 'super_admin'},
"OfftakerPortal": {component: OfftakerPortal, roles: ['super_admin', 'offtaker']},
"ProductivityMonitoring": {component: ProductivityMonitoring, roles:['super_admin', 'petani']},
"Login": {component: Login, roles:[]},
"Register": {component: Register, roles:[]},
+64 -26
View File
@@ -17,37 +17,73 @@ export default function Dashboard() {
less_productive: { label: "Kurang Produktif", color: "bg-amber-100 text-amber-700" },
not_productive: { label: "Tidak Produktif", color: "bg-red-100 text-red-700" }
};
const tok = localStorage.getItem('access_token');
const us = localStorage.getItem('user_data');
console.log('user data dan token ', tok,us)
const { data: rawfarmers = [] } = useQuery({
queryKey: ["farmers"],
queryFn: async () => {
const res = await entity('map','tanaman').list();
return res.data;
const res = await entity('auth','profile').list();
const datas = res?.data?.data || res?.data || [];
return datas?.data.filter(data => data?.user?.role.toString() === 'petani');
},
retry: false
});
const { data: rawlands = [] } = useQuery({
queryKey: ["lands"],
queryFn: () => [],
queryFn: async () => {
let allData = [];
let page =1;
let hasNext = true;
while(hasNext){
const res = await entity('map','lahan').list();
const mappedData = Array.isArray(res?.data) ? res.data : Array.isArray(res?.data?.data) ? res?.data?.data : [];
allData = [...allData, ...mappedData];
const maxPAge = res?.data?.meta?.last_page || 1;
if(page >= maxPAge) hasNext = false;
else page++;
}
return allData;
},
retry: false
});
const { data: rawplants = [] } = useQuery({
queryKey: ["plants"],
queryFn: () => [],
queryFn: async () => {
const fetchAllPlants = async () => {
const alllData = [];
let page = 1;
let hasNext = true;
while(hasNext){
const res = await entity('map','tanaman').list({ page, per_page: 100 });
const plants = Array.isArray(res?.data) ? res.data : Array.isArray(res?.data?.data) ? res?.data?.data : [];
alllData.push(...plants);
const maxPAge = res?.data?.meta?.last_page || 1;
if(page >= maxPAge) hasNext = false;
else page++;
}
return alllData;
};
const allPlants = await fetchAllPlants();
return allPlants;
},
retry: false
});
const farmers = (Array.isArray(rawfarmers) && rawfarmers.length > 0) ? rawfarmers : (mockFarmers || []);
const lands = (Array.isArray(rawlands) && rawlands.length > 0) ? rawlands : (mockLands || []);
const lands = (Array.isArray(rawlands) && rawlands.length > 0) ? rawlands : []//(mockLands || []);
const plants = (Array.isArray(rawplants) && rawplants.length > 0) ? rawplants : (mockPlants || []);
const safeFarmers = Array.isArray(farmers) ? farmers : [];
const safeLands = Array.isArray(lands) ? lands : [];
const safeLands = Array.isArray(rawlands) ? rawlands : [];
const safePlants = Array.isArray(plants) ? plants : [];
@@ -55,9 +91,9 @@ export default function Dashboard() {
// const totalArea = Array.isArray(lands) ? lands.reduce((sum, land) => sum + (land.area_hectares || 0), 0) : 0;
// const verifiedFarmers = Array.isArray(farmers) ? farmers.filter(f => f.verification_status === "verified").length : 0;
const validLands = Array.isArray(lands) ? lands.filter(l => l.validation_status === "valid").length : 0;
const alivePlants = Array.isArray(plants) ? plants.filter(p => p.status === "alive").length : 0;
const alivePlants = Array.isArray(plants) ? plants.filter(p => p.kondisi_tanaman === "sehat" || p.kondisi_tanaman === null).length : 0;
const totalArea = safeLands.reduce((sum, land) => sum + (Number(land.area_hectares) || 0), 0);
const totalArea = safeLands.reduce((sum, land) => sum + (parseFloat(land.luas_lahan) || 0), 0);
const verifiedFarmers = safeFarmers.filter(f => f.verification_status === "verified").length;
// Region data
@@ -73,20 +109,22 @@ export default function Dashboard() {
// }, []).sort((a, b) => b.value - a.value).slice(0, 5);
// new
const regionData = safeLands.reduce((acc, land) => {
const region = land.regency || "Tidak Diketahui";
const existing = acc.find(r => r.name === region);
if (existing) {
existing.value += (Number(land.area_hectares) || 0);
} else {
acc.push({ name: region, value: (Number(land.area_hectares) || 0) });
}
const regionMap = safeLands.reduce((acc, land) => {
const region = land?.desa_kelurahan?.kecamatan?.nama || "Tidak Diketahui";
const area = parseFloat(land?.luas_lahan) || 1;
acc[region] = (acc[region] || 0) + area;
return acc;
}, []).sort((a, b) => b.value - a.value).slice(0, 5);
}, {});
const regionData = Object.entries(regionMap)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 5);
// Commodity data
const commodityData = plants.reduce((acc, plant) => {
const commodity = plant.commodity_name || "Lainnya";
const commodity = plant?.komoditas?.nama || "Lainnya";
const existing = acc.find(c => c.name === commodity);
if (existing) {
existing.value += 1;
@@ -99,10 +137,10 @@ export default function Dashboard() {
// Issues
const pendingValidation = lands.filter(l => l.validation_status === "pending" || l.validation_status === "need_review").length;
const unverifiedFarmers = farmers.filter(f => f.verification_status === "pending").length;
const sickPlants = plants.filter(p => p.status === "sick" || p.productivity_status === "less_productive" || p.productivity_status === "not_productive").length;
const sickPlants = plants.filter(p => p.kondisi_tanaman === "sakit" || p.productivity_status === "less_productive" || p.productivity_status === "not_productive").length;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="min-h-full">
<div className="max-w-7xl mx-auto p-6 space-y-8">
{/* Header */}
<motion.div
@@ -227,19 +265,19 @@ export default function Dashboard() {
<div key={farmer.id} className="flex items-center gap-4 p-3 rounded-lg hover:bg-slate-50 transition-colors">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-100 to-emerald-200 flex items-center justify-center">
<span className="text-sm font-bold text-emerald-700">
{farmer.full_name?.charAt(0)?.toUpperCase()}
{farmer.full_name?.charAt(0)?.toUpperCase() || farmer.nama.charAt(0)?.toUpperCase() || '-'}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-900 truncate">{farmer.full_name}</p>
<p className="text-sm text-slate-500">{farmer.village}, {farmer.district}</p>
<p className="font-medium text-slate-900 truncate">{farmer.full_name || farmer?.nama || '-'}</p>
<p className="text-sm text-slate-500">{farmer.village || farmer?.alamat || '-'}</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
farmer.verification_status === "verified"
farmer?.user?.email_verified_at !== null
? "bg-emerald-100 text-emerald-700"
: "bg-amber-100 text-amber-700"
}`}>
{farmer.verification_status === "verified" ? "Terverifikasi" : "Pending"}
{farmer?.user?.email_verified_at !== null ? "Terverifikasi" : "Pending"}
</span>
</div>
))}
+30 -28
View File
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import FarmerForm from "@/components/farmers/FarmerForm";
import {
ArrowLeft, Edit, MapPin, Phone, Users, CreditCard,
Map, Plus, CheckCircle, XCircle, Clock
Map as MapIcon, Plus, CheckCircle, XCircle, Clock
} from "lucide-react";
import LinkUserAccount from "@/components/farmers/LinkUserAccount";
import { toast } from "sonner";
@@ -33,8 +33,8 @@ export default function FarmerDetail() {
const { data: farmers = [] } = useQuery({
queryKey: ["farmers"],
queryFn: async () => {
const data = await entity('farmer').list();
return data ?? [];
const data = await entity('map','petani').list();
return data?.data ? data?.data : data?.data?.data ? data.data.data : [];
},
enabled: true
});
@@ -43,14 +43,16 @@ export default function FarmerDetail() {
queryKey: ['farmer', id],
queryFn: async (data) => {
if(!id) return null;
console.log(id, paramsId);
try{
const response = await entity('map','petani').get(id);
console.log(response);
const response = await entity('auth','profile').findOne(id);
if (typeof response === 'string' && response.includes('<!doctype html>')) {
console.warn("API returned HTML instead of JSON. Falling back to local.");
} else if (response && !response.error) {
return response; // Berhasil ambil JSON
const data = response?.data ? response.data : response?.data?.data ? response.data.data : [];
console.log("API Response for farmer:", data);
return data; // Berhasil ambil JSON
}
} catch (e) {
console.warn("Server unreachable, searching locally...");
@@ -65,7 +67,7 @@ export default function FarmerDetail() {
enabled : !!id,
initialData: null
});
console.log('Farmer Detail', farmer)
// const { data: landsRaw = [] } = useQuery({
// queryKey: ["lands", farmerId],
// queryFn: () => base44.entities.Land.list(),
@@ -78,15 +80,17 @@ export default function FarmerDetail() {
queryFn: async () => {
let serverData = [];
try {
const resp = await entity('land').list("-created_date");
// Cek apakah response string HTML (error fallback server)
const resp = await entity('map', 'lahan').list();
if (typeof resp === 'string' && resp.includes('<!doctype html>')) {
console.warn("Server returned HTML for Land list");
} else if (Array.isArray(resp)) {
serverData = resp;
}
if(resp && !resp.error){
const dataArr = Array.isArray(resp?.data) ? resp.data : Array.isArray(resp?.data?.data) ? resp.data.data : [];
serverData = dataArr;
}
} catch (e) {
console.warn("Server lands unreachable");
console.warn("Server lands unreachable", e);
}
// Ambil data lokal
@@ -95,7 +99,7 @@ export default function FarmerDetail() {
const combined = new Map();
serverData.forEach(l => { if(l.id) combined.set(l.id, l); });
localData.forEach(l => { if(l.id) combined.set(l.id, l); });
console.log("Combined lands from server and local:", combined.size);
const result = Array.from(combined.values());
console.log("🔍 Dexie & Server Merge:", result.length, "items");
return result;
@@ -103,7 +107,6 @@ export default function FarmerDetail() {
});
const lands = useMemo(() => {
// Pastikan menggunakan rawLands (sesuai destructuring di atas)
const safeLands = Array.isArray(rawLands) ? rawLands : [];
// Debugging: Cek apakah ID yang dicari ada di dalam daftar
@@ -111,7 +114,7 @@ export default function FarmerDetail() {
return safeLands.filter(land => {
// Gunakan == untuk jaga-jaga jika ID satu string dan satu number
return land.farmer_id == id;
return land.profile_id == id;
});
}, [rawLands, id]);
@@ -253,8 +256,8 @@ export default function FarmerDetail() {
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold text-slate-900">{farmer.full_name}</h1>
<p className="text-slate-500">NIK: {farmer.nik}</p>
<h1 className="text-2xl font-bold text-slate-900">{farmer.nama}</h1>
<p className="text-slate-500">NIK: {farmer.ktp}</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -305,14 +308,14 @@ export default function FarmerDetail() {
<div className="flex flex-col items-center text-center mb-6">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-emerald-100 to-emerald-200 flex items-center justify-center mb-4">
{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.nama} className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-3xl font-bold text-emerald-700">
{farmer.full_name?.charAt(0)?.toUpperCase()}
{farmer.nama?.charAt(0)?.toUpperCase()}
</span>
)}
</div>
<h3 className="font-semibold text-lg text-slate-900">{farmer.full_name}</h3>
<h3 className="font-semibold text-lg text-slate-900">{farmer.nama}</h3>
<div className={`flex items-center gap-2 mt-2 px-3 py-1.5 rounded-full ${statusConfig.bg} ${statusConfig.border} border`}>
<StatusIcon className={`w-4 h-4 ${statusConfig.text}`} />
<span className={`text-sm font-medium ${statusConfig.text}`}>
@@ -327,15 +330,15 @@ export default function FarmerDetail() {
<CreditCard className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<p className="text-xs text-slate-500">NIK</p>
<p className="font-medium text-slate-900">{farmer.nik}</p>
<p className="font-medium text-slate-900">{farmer.ktp}</p>
</div>
</div>
{farmer.phone && (
{farmer.telepon && (
<div className="flex items-start gap-3">
<Phone className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<p className="text-xs text-slate-500">Telepon</p>
<p className="font-medium text-slate-900">{farmer.phone}</p>
<p className="font-medium text-slate-900">{farmer.telepon}</p>
</div>
</div>
)}
@@ -353,8 +356,7 @@ export default function FarmerDetail() {
<div>
<p className="text-xs text-slate-500">Alamat</p>
<p className="font-medium text-slate-900">
{farmer.village}, {farmer.district}<br/>
{farmer.regency}, {farmer.province}
{farmer.alamat}
</p>
</div>
</div>
@@ -413,7 +415,7 @@ export default function FarmerDetail() {
<Card className="border-0 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle className="text-lg flex items-center gap-2">
<Map className="w-5 h-5 text-emerald-600" />
<MapIcon className="w-5 h-5 text-emerald-600" />
Lahan Terdaftar
</CardTitle>
<Link to={createPageUrl("LandRegister") + `?farmer_id=${id}`}>
@@ -436,7 +438,7 @@ export default function FarmerDetail() {
</div>
) : (
<div className="text-center py-8 text-slate-500">
<Map className="w-12 h-12 mx-auto text-slate-300 mb-3" />
<MapIcon className="w-12 h-12 mx-auto text-slate-300 mb-3" />
<p>Belum ada lahan terdaftar</p>
</div>
)}
+1 -1
View File
@@ -122,7 +122,7 @@ export default function FarmerPortal() {
// Not logged in
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-emerald-50 to-slate-100 flex items-center justify-center p-6">
<div className="min-h-full flex items-center justify-center p-6 ">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
+183 -58
View File
@@ -22,6 +22,7 @@ import {
Edit, Loader2, MapPin, Phone, Users as UsersIcon, ClipboardCheck, AlertTriangle, Bug, Send, Package,
Save, CreditCard, FileText
} from "lucide-react";
import LandInspectionForm from "@/components/lands/LandInspectionForm";
import PlantInspectionForm from "@/components/plants/PlantInspectionForm";
import DistributionForm from "@/components/distribution/DistributionForm";
import HarvestForm from "@/components/harvest/HarvestForm";
@@ -29,6 +30,7 @@ import { toast } from "sonner";
import { motion, sync } from "framer-motion";
import { OfflineService } from "@/components/common/offlineStorage";
import axios from 'axios';
import { cn } from "@/lib/utils";
export default function FarmerPortal() {
@@ -41,6 +43,7 @@ export default function FarmerPortal() {
const [showTagPlant, setShowTagPlant] = useState(false);
const [selectedLand, setSelectedLand] = useState(null);
const [inspectingPlant, setInspectingPlant] = useState(null);
const [inspectingLand, setInspectingLand] = useState(null);
const [showDistribution, setShowDistribution] = useState(false);
const [showHarvestForm, setShowHarvestForm] = useState(false);
// Offline entity hooks
@@ -58,36 +61,73 @@ export default function FarmerPortal() {
villages: []
});
const tpreffixes = [
{key: 'petani', preffix : "PT"}, // PT-fsdnfjs23
{key : 'offtaker' , preffix : "OT"}, // OT-fsdn2js13
{key : 'lahan', preffix: 'LH'}, // LH-jhjbhj433-petani_token
{key : 'tanaman', preffix : 'TN'}, // TN-sjdj342-lahan_token
{key : 'panen', preffix : 'PN'}, // PN-tgl/bulan/tahun-petani_token
{key : 'distribusi', preffix : 'DS'} // DS-petani_token-offtaker_token
];
const generateToken = () => {
return Math.random().toString(36).substring(2,12).toUpperCase();
}
const $farmerToken = tpreffixes.map((data) => {
switch(data.key){
case 'petani':
return `${data.preffix}-${generateToken()}`;
default:
console.log("unknown");
}
})
console.log("Farmer Token => ", $farmerToken[0]);
useEffect(() => {
const prepareDataLocation = async() => {
if(!navigator.onLine){
try{
const [villages, districts, regencies, provinces] = await Promise.all([
const prepareDataLocation = async () => {
// 1. Cek status koneksi
const isOnline = navigator.onLine;
try {
let data;
if (isOnline) {
console.log("Fetching from Server...");
const [v, d, r, p] = await Promise.all([
entity('master', 'desa-kelurahan').list(),
entity('master', 'kecamatan').list(),
entity('master', 'kabupaten-kota').list(),
entity('master', 'provinsi').list(),
]);
data = {
villages: v?.data?.data || v?.data || [],
districts: d?.data?.data || d?.data || [],
regencies: r?.data?.data || r?.data || [],
provinces: p?.data?.data || p?.data || []
};
} else {
// JIKA OFFLINE: Ambil dari Dexie / SQLite
console.log("Loading from Offline Storage...");
const [v, d, r, p] = await Promise.all([
OfflineService.getEntities('villages'),
OfflineService.getEntities('districts'),
OfflineService.getEntities('regencies'),
OfflineService.getEntities('provinces'),
]);
setLocationData({
villages: villages, districts:districts, regencies:regencies,provinces:provinces});
}catch(err){
console.error(err)
data = {
villages: v || [],
districts: d || [],
regencies: r || [],
provinces: p || []
};
}
}else{
try{
const [villages, districts, regencies, provinces] = await Promise.all([
OfflineService.getEntities('villages'),
OfflineService.getEntities('districts'),
OfflineService.getEntities('regencies'),
OfflineService.getEntities('provinces'),
]);
setLocationData({villages: villages, districts:districts, regencies:regencies,provinces:provinces});
console.log("Offline")
}catch(err){
console.error(err)
}
setLocationData(data);
} catch (err) {
console.error("Gagal memuat data lokasi:", err);
}
};
@@ -155,6 +195,7 @@ export default function FarmerPortal() {
try{
const resp = await entity('distribusi','panen').list();
let data = resp.data || [];
data = data.filter((dt) => dt.farmer_id === farmer.id)
return data;
@@ -177,9 +218,11 @@ export default function FarmerPortal() {
queryFn: async() => {
try{
const response = await entity('map','panen').list();
const data = response.data ?? [];
const data = response?.data ? response.data : response?.data?.data ? response.data.data : [];
if(data && data.length > 0){
return data;
const filtered = data.filter((dt) => dt.farmer_id === farmer.profile_id);
return filtered;
}
return []
}catch(err){
@@ -198,21 +241,68 @@ export default function FarmerPortal() {
enabled: !!farmer?.id
});
// const { data: landInspections = [] } = useQuery({
// queryKey: ["land-inspections", farmer?.id],
// queryFn: async () => {
// try {
// const response = await entity('map', 'inspeksi/lahan').list();
// return Array.isArray(response.data) ? response.data : Array.isArray(response?.data?.data) ? response.data.data : [];
// } catch (error) {
// console.error("Gagal mengambil data inspeksi lahan:", error);
// return [];
// }
// },
// enabled: !!farmer?.id
// });
const { data: lands = [] } = useQuery({
queryKey: ["lands", farmer?.id],
queryFn: async () => {
let lahans = [];
try{
const listLahan = await entity('map', 'lahan').list();
lahans = Array.isArray(listLahan.data)
? listLahan.data.filter(l => l.profile_id === profile.id)
: [];
const fetchInspections = async () => {
const response = await entity('map', 'inspeksi/lahan').list();
return Array.isArray(response.data) ? response.data : Array.isArray(response?.data?.data) ? response.data.data : [];
}
const fetchAll = async () => {
let allLahans = [];
let page =1;
let hasMore = true;
while(hasMore){
const resp = await entity('map', 'lahan').list({ page:page});
const data = Array.isArray(resp.data) ? resp.data : Array.isArray(resp?.data?.data) ? resp.data.data : [];
allLahans = [...allLahans, ...data];
const lastPage = resp?.data?.meta?.last_page || 1;
if( page >= lastPage){
hasMore = false;
}else{
page++;
}
}
return allLahans;
}
const AllLahans = await fetchAll();
const landInspections = await fetchInspections();
console.log("All Lahans => ", AllLahans, " Land Inspections => ", landInspections)
lahans = AllLahans
.filter(l => l.profile_id === profile.id)
.map(lahan => {
const inspections = safeArray(landInspections).filter(ins => ins.lahan_id === lahan.id);
return {
...lahan,
last_inspection: inspections.length > 0 ? inspections[inspections.length - 1] : null
};
});
}catch(err){
console.warn("Online data is un reachable")
console.warn("Online data is un reachable", err)
}
try{
console.log(profile?.id || 0, "PRofile id")
const offlineData = await OfflineService.getEntities('lands');
const offlineLahan = safeArray(offlineData).filter(lh => lh.profile_id === profile.id);
const combined = new Map();
@@ -225,19 +315,20 @@ export default function FarmerPortal() {
);
}catch(err){
console.error(err)
return lahans;
}
},
enabled: !!farmer?.id
enabled: !!farmer?.id && !!profile?.id
});
console.log("Data Lahan => ", lands)
const { data: plants = [] } = useQuery({
queryKey: ["farmer-plants", farmer?.id],
queryFn: async () => {
try {
const [response, inspctResponse] = await Promise.all([
entity('map', 'tanaman').list(),
entity('map', 'inspeksi').list()
entity('map', 'inspeksi/tanaman').list()
]);
const $rawPlants = response.data.data ?? [];
@@ -256,15 +347,14 @@ export default function FarmerPortal() {
const mergedPlants = $rawPlants.map((plant) => {
const inspections = inspectionMap.get(plant.id.toString()) || [];
return {
...plant,
inspections: inspections,
latest_inspection: inspections.length > 0 ? inspections[inspections.length - 1] : null
};
});
return mergedPlants;
// const filteredPlants = mergedPlants.filter(p => p.lahan.profile_id === farmer.profile_id);
return mergedPlants.filter(p => Number(p.lahan.profile_id) === Number(farmer.profile_id));
} catch (error) {
console.error("Gagal mengambil data gabungan:", error);
return [];
@@ -282,6 +372,7 @@ export default function FarmerPortal() {
return await entity('map', 'lahan').create({
...data,
farmer_id: farmer.id,
profile_id: profile?.id,
validation_status: "pending",
sync_status: "pending"
})
@@ -311,7 +402,7 @@ export default function FarmerPortal() {
});
// Invalidate agar UI mengambil data dari local DB (IndexedDB)
queryClient.invalidateQueries({ queryKey: ["farmer-lands"] });
queryClient.invalidateQueries({ queryKey: ["lands", farmer?.id] });
// resetForm();
toast.info("Data disimpan secara lokal (Offline)");
} catch (localErr) {
@@ -338,12 +429,15 @@ export default function FarmerPortal() {
});
const createHarvestMutation = useMutation({
mutationFn: (data) => harvestEntity.create({
mutationFn: (data) => {
const payloads = {
...data,
farmer_id: farmer.id,
sync_status: "pending",
created_at: new Date().toISOString()
}),
}
return entity('map', 'panen').create(payloads);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["farmer-harvests"] });
setShowHarvestForm(false);
@@ -354,7 +448,7 @@ export default function FarmerPortal() {
const updateProfileMutation = useMutation({
mutationFn: async (data) => {
console.log(profile)
const token = localStorage.getItem("access_token");
try{
const response = await axios.post(
@@ -622,11 +716,9 @@ export default function FarmerPortal() {
);
}
// Logged in but no farmer profile linked
if (!farmer) {
return (
<div className="min-h-screen bg-gradient-to-br from-emerald-50 to-slate-100 flex items-center justify-center p-6">
<div className="min-h-full flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@@ -681,7 +773,7 @@ export default function FarmerPortal() {
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-emerald-50 to-slate-100">
<div className="min-h-sfull">
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
{/* Sync Status Bar */}
<SyncStatusBar />
@@ -753,24 +845,25 @@ export default function FarmerPortal() {
transition={{ delay: 0.2 }}
>
<Tabs defaultValue="lands" className="space-y-4">
<TabsList className="w-full bg-white shadow-sm p-1 h-auto flex-wrap">
<TabsTrigger value="lands" className="flex-1 py-3 data-[state=active]:bg-emerald-50 data-[state=active]:text-emerald-700">
<TabsList className="w-full bg-white shadow-sm p-1 h-auto flex-wrap gap-1">
<TabsTrigger value="lands" className={cn("flex-1 py-3 data-[state=active]:bg-emerald-50")}>
<MapPin className="w-4 h-4 mr-2" />
Lahan
</TabsTrigger>
<TabsTrigger value="plants" className="flex-1 py-3 data-[state=active]:bg-emerald-50 data-[state=active]:text-emerald-700">
{/* <TabsTrigger value="plants" className="flex-1 py-3 data-[state=active]:bg-emerald-50 data-[state=active]:text-emerald-700"> */}
<TabsTrigger value="plants" className="flex-1 py-3 data-[state=active]:bg-emerald-50">
<TreePine className="w-4 h-4 mr-2" />
Tanaman
</TabsTrigger>
<TabsTrigger value="harvest" className="flex-1 py-3 data-[state=active]:bg-emerald-50 data-[state=active]:text-emerald-700">
<TabsTrigger value="harvest" className="flex-1 py-3 data-[state=active]:bg-emerald-50">
<Package className="w-4 h-4 mr-2" />
Panen
</TabsTrigger>
<TabsTrigger value="distribution" className="flex-1 py-3 data-[state=active]:bg-emerald-50 data-[state=active]:text-emerald-700">
<TabsTrigger value="distribution" className="flex-1 py-3 data-[state=active]:bg-emerald-50">
<Truck className="w-4 h-4 mr-2" />
Distribusi
</TabsTrigger>
<TabsTrigger value="profile" className="flex-1 py-3 data-[state=active]:bg-emerald-50 data-[state=active]:text-emerald-700">
<TabsTrigger value="profile" className="flex-1 py-3 data-[state=active]:bg-emerald-50">
<User className="w-4 h-4 mr-2" />
Profil
</TabsTrigger>
@@ -780,7 +873,7 @@ export default function FarmerPortal() {
<TabsContent value="lands" className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-slate-900">Daftar Lahan</h2>
<Button size="sm" onClick={() => setShowAddLand(true)} className="bg-emerald-600 hover:bg-emerald-700">
<Button size="sm" onClick={() => setShowAddLand(true)} className={cn("bg-rose-600 hover:bg-rose-700")}>
<Plus className="w-4 h-4 mr-2" />
Tambah Lahan
</Button>
@@ -792,7 +885,8 @@ export default function FarmerPortal() {
<div key={land.id} className="relative">
<LandCard
land={land}
plantCount={plants.filter(p => p.lahan_id === land.id).length}
plantCount={plants.filter(p => p.lahan.id === land.id).length}
lastInspection={land.last_inspection}
/>
<Button
size="sm"
@@ -804,7 +898,18 @@ export default function FarmerPortal() {
}}
>
<TreePine className="w-4 h-4 mr-1" />
Tag Tanaman
Tag
</Button>
<Button
size="sm"
variant="outline"
className="absolute top-4 right-24"
onClick={() => {
setInspectingLand(land)
}}
>
<ClipboardCheck className="w-4 h-4" />
Ins
</Button>
</div>
))}
@@ -829,6 +934,7 @@ export default function FarmerPortal() {
{plants.map(plant => {
const land = lands.find(l => l.id === plant.lahan_id);
const hasIssue = plant.inspections?.[0]?.issue_type && plant.inspections?.[0]?.issue_type !== "none";
return (
<Card key={plant.id} className="border-0 shadow-sm p-4">
<div className="flex items-center justify-between">
@@ -837,12 +943,12 @@ export default function FarmerPortal() {
plant.inspections?.[0]?.productivity_status === "productive" ? "bg-emerald-500" :
plant.inspections?.[0]?.productivity_status === "less_productive" ? "bg-amber-500" :
plant.inspections?.[0]?.productivity_status === "not_productive" ? "bg-red-500" :
plant.status === "sick" ? "bg-red-500" :
plant.health_status === "severe_issue" ? "bg-red-500" :
plant.status === "dead" ? "bg-slate-400" : "bg-emerald-500"
}`} />
<div>
<p className="font-medium text-slate-900">{plant.nama}</p>
<p className="text-sm text-slate-500">{land?.nama || "Lahan tidak diketahui"}</p>
<p className="text-sm text-slate-500">{plant?.lahan.nama || "Lahan tidak diketahui"}</p>
{hasIssue && (
<div className="flex items-center gap-1 mt-1">
<Bug className="w-3 h-3 text-amber-500" />
@@ -902,7 +1008,9 @@ export default function FarmerPortal() {
{harvests.length > 0 ? (
<div className="grid gap-3">
{harvests.map(harvest => (
{harvests.map(harvest => {
console.log("Harvest => ", harvest, "Lahan => ", lands.find(l => l.id === harvest.land_id))
return (
<Card key={harvest.id} className="border-0 shadow-sm p-4">
<div className="flex justify-between items-start">
<div>
@@ -915,10 +1023,10 @@ export default function FarmerPortal() {
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
<MapPin className="w-3 h-3" />
{lands.find(l => l.id === harvest.land_id)?.name || "Lahan Umum"}
{lands.find(l => l.id === harvest.land_id)?.nama || "Lahan Umum"}
</div>
</Card>
))}
)})}
</div>
) : (
<EmptyState
@@ -1322,7 +1430,7 @@ export default function FarmerPortal() {
{inspectingPlant && (
<PlantInspectionForm
plant={inspectingPlant}
farmer_id={farmer?.id}
farmer_id={farmer?.profile_id}
onClose={() => setInspectingPlant(null)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ["farmer-plants"] });
@@ -1353,6 +1461,7 @@ export default function FarmerPortal() {
</DialogHeader>
<div className="p-6">
<HarvestForm
farmer={farmer}
lands={lands}
plants={plants}
onSubmit={(data) => createHarvestMutation.mutate(data)}
@@ -1362,6 +1471,22 @@ export default function FarmerPortal() {
</div>
</DialogContent>
</Dialog>
{/* Inspection Land Dialog */}
<Dialog open={!!inspectingLand} onOpenChange={() => setInspectingLand(null)}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto p-0">
{inspectingLand && (
<LandInspectionForm
land={inspectingLand}
farmer_id={farmer?.id}
onClose={() => setInspectingLand(null)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ["farmer-lands"] });
}}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
+55 -30
View File
@@ -1,5 +1,6 @@
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect, useRef } from "react";
import base44 from "@/api/Client";
import { entity } from "@/api/entities";
import { createPageUrl } from "@/utils";
@@ -9,7 +10,6 @@ import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { motion } from "framer-motion";
import { Link } from "react-router-dom";
// Import Offline Service yang baru kita buat
import { OfflineService } from "@/components/common/offlineStorage";
export default function FarmerRegister() {
@@ -19,14 +19,39 @@ export default function FarmerRegister() {
const createMutation = useMutation({
mutationFn: async (data) => {
console.log("1. Memulai proses simpan...", data);
try {
// return await base44.entities.Farmer.create(data);
const response = await entity('map', 'lahan').create(data);
if (typeof response === 'string' && response.includes('<!doctype html>')) {
throw new Error("Menerima HTML, bukan JSON. Endpoint mungkin salah.");
if(!data?.password && !data?.password_confirmation){
console.log("password", data.password, data.password_confirmation)
return toast.error("Passwod Harus dibuat");
}
console.log("2. Berhasil simpan ke server", response);
return response;
if (data?.password !== data?.password_confirmation) {
console.log("password tidak cocol", data.password, data.password_confirmation);
return toast.error("Konfirmasi password tidak cocok");
}
try {
// check user
const respupdate = await entity('auth', `profile`).find({email:data?.email});
const dataToUpdate = Array.isArray(respupdate?.data) ? respupdate?.data[0] : Array.isArray(respupdate?.data?.data) ? respupdate.data?.data[0] : [];
let profileId = dataToUpdate?.id;
//create user
if(!profileId){
const response = await base44.post("/auth/signup", {
name: data.nama || '',
email: data.email || '',
password: data.password,
password_confirmation: data.password_confirmation,
role: "petani",
});
const check = await entity('auth', `profile`).find({email:data?.email});
const checkId = Array.isArray(check?.data) ? check?.data[0] : Array.isArray(check?.data?.data) ? check.data?.data[0] : [];
profileId = checkId?.id;
}
const resp = await entity('auth', `profile`).update(profileId, {...data});
const dt = Array.isArray(resp?.data) ? resp.data : Array.isArray(resp?.data?.data) ? resp.data.data : [];
return dt;
} catch (error) {
console.log("2. Gagal ke server, beralih ke offline mode", error);
throw { type: 'OFFLINE_SAVE', data };
@@ -37,30 +62,30 @@ export default function FarmerRegister() {
toast.success("Petani berhasil didaftarkan ke server!");
navigate(createPageUrl("Farmers"));
},
onError: async (error, variables) => {
if (error.type === 'OFFLINE_SAVE') {
try {
// 1. Simpan ke SQLite
const entity = 'farmer';
const localId = await OfflineService.saveEntityLocally(entity,variables);
const newFarmer = { ...variables, id: localId, sync_status: 'pending' }
// 2. Update Cache TanStack Query secara manual (Optimistic Update)
queryClient.setQueryData(["farmers"], (oldData) => {
return oldData ? [newFarmer, ...oldData] : [newFarmer];
});
// onError: async (error, variables) => {
// if (error.type === 'OFFLINE_SAVE') {
// try {
// // 1. Simpan ke SQLite
// const entity = 'farmer';
// const localId = await OfflineService.saveEntityLocally(entity,variables);
// const newFarmer = { ...variables, id: localId, sync_status: 'pending' }
// // 2. Update Cache TanStack Query secara manual (Optimistic Update)
// queryClient.setQueryData(["farmers"], (oldData) => {
// return oldData ? [newFarmer, ...oldData] : [newFarmer];
// });
toast.info("Tersimpan secara lokal");
// 3. Beri sedikit jeda agar Toast/Cache sempat terproses sebelum pindah halaman
setTimeout(() => {
navigate(createPageUrl("Farmers"));
}, 500);
// toast.info("Tersimpan secara lokal");
// // 3. Beri sedikit jeda agar Toast/Cache sempat terproses sebelum pindah halaman
// setTimeout(() => {
// navigate(createPageUrl("Farmers"));
// }, 500);
} catch (sqlError) {
console.error("SQLite Error:", sqlError);
toast.error("Gagal simpan lokal");
}
}
}
// } catch (sqlError) {
// console.error("SQLite Error:", sqlError);
// toast.error("Gagal simpan lokal");
// }
// }
// }
});
return (
+13 -9
View File
@@ -23,13 +23,18 @@ export default function Farmers() {
queryFn: async () => {
let serverData;
try {
serverData = await entity("auth","users").list();
const fetch = await entity("auth","profile").list();
if(fetch.status !== 200) {
throw new Error("Failed to fetch from server");
}
const fetchData = Array.isArray(fetch?.data) ? fetch?.data : Array.isArray(fetch?.data?.data) ? fetch.data.data : [];
serverData = fetchData.filter(item => item?.user.role === "petani");
} catch (err) {
console.warn("Server unreachable, loading local data only");
console.warn("Server unreachable, loading local data only", err);
}
const localData = await OfflineService.getEntities("farmers");
const combined = new Map();
console.log("Data Local => ", localData);
safeArray(serverData).forEach(f => combined.set(f.id, f));
safeArray(localData).forEach(f => combined.set(f.id, f));
@@ -51,15 +56,14 @@ export default function Farmers() {
farmer.nik?.includes(search) || farmer.kk?.includes(search) || farmer.ktp?.includes(search) ||
farmer.farmer_group?.toLowerCase().includes(search.toLowerCase());
const currentStatus = farmer.user.email_verified_at !== null || farmer.user.email_verified_at !== undefined ? 'verified' : 'pending';
const isVerified = farmer?.user?.email_verified_at != null;
const currentStatus = isVerified? 'verified' : 'pending';
const matchStatus = !statusFilter || statusFilter === "all" || currentStatus === statusFilter;
const matchRegion = !regionFilter || regionFilter === "all" || farmer.regency === regionFilter;
const matchStatus = statusFilter === "all" || currentStatus === statusFilter;
const matchRegion = regionFilter === "all" || farmer.regency === regionFilter;
console.log("status filter => ", matchStatus, farmer.user.email_verified_at, statusFilter);
return matchSearch && matchStatus && matchRegion;
});
console.log("Filtered Farmers => ", filteredFarmers);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="max-w-7xl mx-auto p-6 space-y-6">
+62 -13
View File
@@ -16,12 +16,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import GPSLandMapper from "@/components/lands/GPSLandMapper";
import {
ArrowLeft, Edit, MapPin, User, Maximize2, TreePine, Loader2,
Plus, CheckCircle, AlertTriangle, Clock, XCircle
Plus, CheckCircle, AlertTriangle, Clock, XCircle, History
} from "lucide-react";
import { toast } from "sonner";
import { motion } from "framer-motion";
import { OfflineService } from "@/components/common/offlineStorage";
import { entity } from "@/api/entities";
import { forEach } from "lodash";
import { da } from "date-fns/locale";
const validationColors = {
valid: { bg: "bg-emerald-50", border: "border-emerald-200", text: "text-emerald-700", icon: CheckCircle },
@@ -112,7 +114,7 @@ export default function LandDetail() {
let serverData = [];
try {
const respon = await entity('map','lahan').findOne(id);
const found = respon.data || respon;
const found = respon?.data?.data ? respon.data.data : null;
if (respon && !respon.error && typeof respon !== 'string') {
if (found) return {
@@ -145,6 +147,22 @@ export default function LandDetail() {
initialData: null
});
const {data: landInspection = []} = useQuery({
queryKey: ["landInspection", id],
queryFn: async () => {
try {
const response = await entity('map', 'inspeksi/lahan').list();
const dataRaw = Array.isArray(response.data) ? response.data : Array.isArray(response.data.data) ? response.data.data : [];
const filtered = dataRaw.filter((ins) => Number(ins.lahan_id) === Number(id));
return filtered;
} catch (e) {
console.error("Error fetching land inspection data:", e);
return [];
}
}
});
const { data: farmer } = useQuery({
queryKey: ["farmer", land?.farmer_id],
queryFn: async () => {
@@ -165,9 +183,10 @@ export default function LandDetail() {
try {
const response = await entity('map', 'tanaman').list({ q: JSON.stringify({ land_id: id }) });
const rawTanaman = Array.isArray(response.data.data) ? response.data.data : [];
if(rawTanaman && rawTanaman.length > 0){
const retPlants = rawTanaman.filter((data) => data.lahan_id === land.id)
return rawTanaman.filter((data) => data.lahan_id === land.id)
return rawTanaman.filter((data) => data.lahan.id === land.id)
}else{
console.log("tidak ada")
}
@@ -184,23 +203,22 @@ export default function LandDetail() {
queryKey: ["inspections", id],
queryFn: async () => {
try {
const response = await base44.entities.PlantInspection.list();
return Array.isArray(response) ? response : [];
const response = await entity('map', 'inspeksi/tanaman').list({ q: JSON.stringify({ land_id: id }) });
return Array.isArray(response.data) ? response.data : Array.isArray(response.data.data) ? response.data.data : [];
} catch (e) {
// Ambil dari Dexie jika ada
const local = await OfflineService.getEntities("plant_inspections");
const local = await OfflineService.getEntities("plant_inspections", { land_id: land.id });
return Array.isArray(local) ? local : [];
}
}
});
const activePlants = plants.filter(p => p?.status !== 'harvested');
// const activePlants = plants.filter(p => p?.status !== 'harvested' && p.lahan_id === land.id);
const combinePlants = useMemo(() => {
const latestPlantInspection = new Map();
inspections.forEach((ins) => {
const key = `${ins.farmer_id}_${ins.land_id}_${ins.plant_id}`;
if (!latestPlantInspection.has(key) ||
@@ -301,6 +319,7 @@ export default function LandDetail() {
const statusConfig = validationColors[land.validation_status] || validationColors.pending;
const StatusIcon = statusConfig.icon;
const village = villages.find((dt) => dt.id === land.desa_kelurahan_id);
console.log("land inspections => ", land);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="max-w-6xl mx-auto p-6 space-y-6">
@@ -317,8 +336,8 @@ export default function LandDetail() {
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold text-slate-900">{land.nama}</h1>
<p className="text-slate-500">{village?.nama || '-'}</p>
<h1 className="text-xl font-bold text-slate-900">{land.nama || land.name}</h1>
<p className="text-slate-500">{land?.desa_kelurahan?.nama || '-'}</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -398,7 +417,7 @@ export default function LandDetail() {
<div className="space-y-2">
<Label>Nama/Label Lahan</Label>
<Input
value={formData.name}
value={formData.name || formData.nama}
onChange={(e) => handleChange("name", e.target.value)}
placeholder="Contoh: Kebun Kopi Utara"
required
@@ -523,8 +542,7 @@ export default function LandDetail() {
<div>
<p className="text-xs text-slate-500">Lokasi</p>
<p className="font-medium text-slate-900">
{village?.nama}, {land.district}<br/>
{land.regency}
{land?.desa_kelurahan?.nama}, {land?.desa_kelurahan?.kecamatan?.nama}<br/>
</p>
</div>
</div>
@@ -622,6 +640,37 @@ export default function LandDetail() {
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<History className="w-5 h-5 text-emerald-600" />
Riwayat Inspeksi
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-60 overflow-y-auto">
{landInspection.map((ins) => (
<div key={ins.id} className="flex items-center justify-between p-2 bg-slate-50 rounded-lg">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full `} />
<span className="text-sm font-medium">{new Date(ins.created_at).toLocaleDateString()}</span>
</div>
<span className="text-xs text-slate-500">
<details>
<summary>Detail</summary>
<p>Kadar Air : {ins.kadar_air}%</p>
<p>PH : {ins.ph}%</p>
<p>Kelembapan : {ins.kelembapan}</p>
<p>Temperatur : {ins.temperatur}</p>
<p>Catatan : {ins.catatan}</p>
</details>
</span>
</div>
))}
</div>
</CardContent>
</Card>
</motion.div>
{/* Right Column - Map */}
+159 -50
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import base44 from "@/api/Client";
@@ -21,22 +21,12 @@ export default function LandRegister() {
const queryClient = useQueryClient();
const urlParams = new URLSearchParams(window.location.search);
const preselectedFarmerId = urlParams.get("farmer_id");
// const [formData, setFormData] = useState({
// farmer_id: preselectedFarmerId || "",
// name: "",
// land_status: "owned",
// village: "",
// district: "",
// regency: "",
// polygon_coordinates: null,
// center_lat: null,
// center_lng: null,
// area_hectares: null,
// validation_status: "pending",
// sync_status: "pending"
// });
const [locationData, setLocationData ] = useState({
provinces: [],
regencies: [],
districts: [],
villages: []
});
const [formData, setFormData] = useState({
nama : '',
@@ -52,29 +42,31 @@ export default function LandRegister() {
deleted_by : '',
});
// const { data: farmers = [] } = useQuery({
// queryKey: ["farmers"],
// queryFn: () => base44.entities.Farmer.list()
// });
const { data: rawFarmers = [] } = useQuery({
queryKey: ["farmers"],
queryFn: async () => {
try {
const response = await entity("map","petani").list();
const response = await entity("auth","profile").list();
const rawData = response?.data?.data || response?.data || [];
console.log("Struktur Response:", JSON.stringify(response));
// Pastikan response adalah array
const serverData = Array.isArray(response) ? response : [];
// const fetchData = Array.isArray(response.data) ? response.data : Array.isArray(response.data?.data) ? response.data?.data : [];
const serverData = rawData.filter(item => item?.user?.role === "petani");
// Gabungkan dengan data lokal dari Dexie
const localData = await OfflineService.getEntities("farmers");
const combined = new Map();
serverData.forEach(f => combined.set(f.id, f));
localData.forEach(f => combined.set(f.id, f));
const combined = {};
serverData.forEach(f => {
if (f?.id) combined[String(f.id)] = f;
});
localData.forEach(f => {
if (f?.id) combined[String(f.id)] = f;
});
return Array.from(combined.values());
return Object.values(combined);
} catch (err) {
console.warn("Gagal ambil petani dari server, mencoba ambil lokal...");
console.warn("Gagal ambil data dari server", err);
console.error("DEBUG ERROR SERVER:", err.message, err.stack);
return await OfflineService.getEntities("farmers");
}
}
@@ -166,16 +158,22 @@ export default function LandRegister() {
// };
const handlePolygonSave = (polygonData) => {
const cleanCoordinates = JSON.parse(JSON.stringify(polygonData.polygon_coordinates));
if (!polygonData || !polygonData.polygon_coordinates) {
console.warn("Data polygon tidak valid");
return;
}
// const cleanCoordinates = JSON.parse(JSON.stringify(polygonData.polygon_coordinates));
const coords = polygonData.polygon_coordinates;
console.log(cleanCoordinates)
setFormData(prev => ({
...prev,
// polygon_coordinates: cleanCoordinates,
path : JSON.stringify(cleanCoordinates),
center_lat: Number(polygonData.center_lat),
center_lng: Number(polygonData.center_lng),
area_hectares: Number(polygonData.area_hectares),
luas_lahan: polygonData.area_hectares
path : JSON.stringify(coords),
center_lat: Number(polygonData.center_lat) || 0,
center_lng: Number(polygonData.center_lng) || 0,
area_hectares: Number(polygonData.area_hectares) || 0,
luas_lahan: polygonData.area_hectares || 0
}));
toast.success(`Polygon disimpan! Luas: ${polygonData.area_hectares.toFixed(4)} Ha`);
@@ -183,13 +181,16 @@ export default function LandRegister() {
const handleSubmit = (e) => {
e.preventDefault();
console.log("Submission start");
if (!formData.farmer_id) {
console.log("No Farmer selected");
toast.error("Pilih petani terlebih dahulu");
return;
}
if (!formData.center_lat || !formData.center_lng) {
console.log("No center lat long");
toast.error("Gambar polygon lahan di peta");
return;
}
@@ -200,9 +201,79 @@ export default function LandRegister() {
// Auto-fill address from farmer
// const selectedFarmer = farmers.find(f => f.id === formData.farmer_id);
const selectedFarmer = Array.isArray(farmers)
? farmers.find(f => f.id === formData.farmer_id)
? farmers.find(f => Number(f.id) === Number(formData.farmer_id))
: null;
console.log(rawFarmers);
useEffect(() => {
const autoFillLocation = async () => {
if(!formData.desa_kelurahan_id) return;
const selectedVillage = locationData.villages.find(data => formData.desa_kelurahan_id === data.id);
if(!selectedVillage) return;
console.log(selectedVillage, "Selected Village");
if (selectedVillage) {
const selectedDistrict = selectedVillage.kecamatan;
const selectedRegency = selectedDistrict?.kabupatenKota;
const selectedProvince = selectedRegency?.provinsi;
setLocationData((prev) => ({
...prev,
districts: selectedDistrict ? [selectedDistrict] : [],
regencies: selectedRegency ? [selectedRegency] : [],
provinces: selectedProvince ? [selectedProvince] : [],
}));
}
};
autoFillLocation();
}, [formData.desa_kelurahan_id, locationData.villages]);
useEffect(() => {
const prepareDataLocation = async () => {
// 1. Cek status koneksi
const isOnline = navigator.onLine;
try {
let data;
if (isOnline) {
console.log("Fetching from Server...");
const [v, d, r, p] = await Promise.all([
entity('master', 'desa-kelurahan').list(),
entity('master', 'kecamatan').list(),
entity('master', 'kabupaten-kota').list(),
entity('master', 'provinsi').list(),
]);
data = {
villages: v?.data?.data || v?.data || [],
districts: d?.data?.data || d?.data || [],
regencies: r?.data?.data || r?.data || [],
provinces: p?.data?.data || p?.data || []
};
} else {
// JIKA OFFLINE: Ambil dari Dexie / SQLite
console.log("Loading from Offline Storage...");
const [v, d, r, p] = await Promise.all([
OfflineService.getEntities('villages'),
OfflineService.getEntities('districts'),
OfflineService.getEntities('regencies'),
OfflineService.getEntities('provinces'),
]);
data = {
villages: v || [],
districts: d || [],
regencies: r || [],
provinces: p || []
};
}
setLocationData(data);
} catch (err) {
console.error("Gagal memuat data lokasi:", err);
}
};
prepareDataLocation();
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="max-w-5xl mx-auto p-6 space-y-6">
@@ -247,7 +318,7 @@ export default function LandRegister() {
<SelectContent>
{farmers.map(farmer => (
<SelectItem key={farmer.id} value={farmer.id}>
{farmer.full_name} - {farmer.village}, {farmer.district}
{farmer.nama} - {farmer.alamat}
</SelectItem>
))}
</SelectContent>
@@ -300,28 +371,66 @@ export default function LandRegister() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Desa</Label>
<Input
value={formData.desa_kelurahan_id}
onChange={(e) => handleChange("desa_kelurahan_id", e.target.value)}
placeholder="Nama desa"
className="h-11"
/>
<Select
name="desa_kelurahan_id"
value={formData?.desa_kelurahan_id?.toString()}
onValueChange={(e) => setFormData((prev) => ({
...prev,
desa_kelurahan_id: parseInt(e)
}))}
>
<SelectTrigger className="!bg-transparent !text-black font-bold text-blue-700"> {/* Berikan placeholder agar tidak kosong saat awal */}
<SelectValue placeholder="Pilih Desa/Kelurahan" />
</SelectTrigger>
<SelectContent>
{locationData && locationData.villages.length > 0 ? (
locationData.villages.map((data) => (
<SelectItem
key={data?.id}
value={data?.id.toString()} // Ini String
>
{data.nama}
</SelectItem>
))
) : (
<div className="p-2 text-sm text-muted-foreground">Memuat Data...</div>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Kecamatan</Label>
<Input
value={formData.district}
onChange={(e) => handleChange("district", e.target.value)}
id="kecamatan"
value={locationData.districts[0]?.nama || "-"}
onChange={(e) => handleChange("kecamatan_id", e.target.value)}
placeholder="Nama kecamatan"
required
className="h-11"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="kabkota_id">Kabupaten/Kota</Label>
<Input
id="regency"
value={locationData.regencies[0]?.nama || "-"}
onChange={(e) => handleChange("kabkota_id", e.target.value)}
placeholder="Nama kabupaten/kota"
required
className="h-11"
/>
</div>
<div className="space-y-2">
<Label>Kabupaten</Label>
<Label htmlFor="provinsi">Provinsi</Label>
<Input
value={formData.regency}
onChange={(e) => handleChange("regency", e.target.value)}
placeholder="Nama kabupaten"
id="provinsi"
value={locationData.provinces[0]?.nama || "-"}
onChange={(e) => handleChange("provinsi", e.target.value)}
placeholder="Nama Provinsi"
required
className="h-11"
/>
</div>
+75 -38
View File
@@ -13,6 +13,7 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Map as MapIcon, Plus, Search, Filter, LayoutGrid, MapPinned } from "lucide-react";
import { motion } from "framer-motion";
import { moodConfig } from "@/utils/moodColor";
import { OfflineService } from "@/components/common/offlineStorage";
export default function Lands() {
@@ -20,46 +21,81 @@ import { OfflineService } from "@/components/common/offlineStorage";
const [statusFilter, setStatusFilter] = useState("all");
const [view, setView] = useState("grid");
// const { data: rawlands = [], isLoading } = useQuery({
// queryKey: ["lands"],
// queryFn: () => base44.entities.Land.list("-created_date")
// });
const { data: rawlands = [], isLoading } = useQuery({
queryKey: ["lands"],
queryFn: async () => {
let serverData = [];
let serverInspections = [];
let rawInspections = [];
const fetchAllLands = async () => {
let allLands = [];
let currentPage =1;
let hasNextPage = true;
while (hasNextPage) {
try {
// Tambahkan timeout atau handle jika return HTML
const resp = await entity("map","lahan").list();
console.log(resp)
if (Array.isArray(resp)) serverData = resp;
} catch (e) {
console.warn("Server lands offline/error : ", e);
const resp = await entity("map", "lahan").list({page:currentPage});
const lands = Array.isArray(resp?.data) ? resp.data : Array.isArray(resp?.data?.data) ? resp?.data?.data : [];
allLands = [...allLands, ...lands];
const lastPage = resp?.data?.meta?.last_page || 1;
if(currentPage >= lastPage){
hasNextPage = false;
}else{
currentPage++;
}
}catch(e){
console.warn(`Error fetching lands page ${currentPage}:`, e);
}
}
const localData = await OfflineService.getEntities("lands");
return allLands;
};
const allFetchedLands = await fetchAllLands();
serverData = allFetchedLands;
// try {
// const resp = await entity("map","lahan").list();
// const rawData = Array.isArray(resp?.data) ? resp.data : Array.isArray(resp?.data?.data) ? resp?.data?.data : [];
// if (Array.isArray(rawData)) serverData = rawData;
// } catch (e) {
// console.warn("Server lands offline/error : ", e);
// }
const localData = await OfflineService.getEntities("lands");
const combined = new Map();
// serverData.forEach(l => { if(l.id) combined.set(l.id, l); });
serverData.forEach(l => { if(l.id) combined.set(l.id, l); });
localData.forEach(l => { if(l.id) combined.set(l.id, l); });
const result = Array.from(combined.values());
// console.log("🔍 Total Lands Loaded (Server + Local):", result.length);
return result;
try{
const inspections = await entity("map","inspeksi/lahan").list();
rawInspections = Array.isArray(inspections?.data) ? inspections.data : Array.isArray(inspections?.data?.data) ? inspections?.data?.data : [];
}catch(e){
console.warn("Server inspections offline/error : ", e);
}
const combinedResult = result.map(land => {
const landInspections = rawInspections.filter(i => i.lahan_id === land.id);
return { ...land, inspections: landInspections };
});
return combinedResult;
}
});
// const { data: rawfarmers = [] } = useQuery({
// queryKey: ["farmers"],
// queryFn: () => base44.entities.Farmer.list()
// });
const { data: rawfarmers = [] } = useQuery({
const { data: rawfarmers = [], isFarmerLoading } = useQuery({
queryKey: ["farmers"],
queryFn: async () => {
let serverData = [];
try {
const resp = await entity("map","profile").list();
const resp = await entity("auth","profile").list();
serverData = Array.isArray(resp?.data) ? resp.data : Array.isArray(resp?.data?.data) ? resp?.data?.data : [];
} catch (e) { }
const localData = await OfflineService.getEntities("farmers");
@@ -70,12 +106,12 @@ import { OfflineService } from "@/components/common/offlineStorage";
}
});
const { data: rawplants = [] } = useQuery({
const { data: rawplants = [], isPlantLoading } = useQuery({
queryKey: ["plants"],
queryFn: async () => {
const rawplants = await entity('map','tanaman').list();
const plants = Array.isArray(rawplants?.data) ? rawplants?.data : Array.isArray(rawplants?.data?.data) ? rawplants?.data?.data : [];
console.log(plants, "Tanamana")
return plants
}
});
@@ -92,17 +128,6 @@ import { OfflineService } from "@/components/common/offlineStorage";
return map;
}, [farmers]);
// const filteredLands = lands.filter(land => {
// const matchSearch = !search ||
// land.name?.toLowerCase().includes(search.toLowerCase()) ||
// land.village?.toLowerCase().includes(search.toLowerCase()) ||
// farmerMap[land.farmer_id]?.toLowerCase().includes(search.toLowerCase());
// const matchStatus = statusFilter === "all" || land.validation_status === statusFilter;
// return matchSearch && matchStatus;
// });
const filteredLands = useMemo(() => {
if (!Array.isArray(lands)) return [];
@@ -138,9 +163,18 @@ import { OfflineService } from "@/components/common/offlineStorage";
});
return map;
}, [plants]);
console.log(farmerMap,farmers, "FArmer MAp", plants, lands);
if (isLoading || isFarmerLoading || isPlantLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-app"></div>
<p className="ml-3 text-slate-500">Memuat data lahan...</p>
</div>
);
}
return (
// <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="min-h-full">
<div className="max-w-7xl mx-auto p-6 space-y-6">
{/* Header */}
<motion.div
@@ -153,7 +187,7 @@ import { OfflineService } from "@/components/common/offlineStorage";
<p className="text-slate-500">{lands.length} lahan terdaftar</p>
</div>
<Link to={createPageUrl("LandRegister")}>
<Button className="bg-emerald-600 hover:bg-emerald-700 h-11">
<Button className="bg-emerald-600 hover:bg-emerald-700 h-11" style={{ backgroundColor: 'var(--primary-app)' }}>
<Plus className="w-5 h-5 mr-2" />
Tambah Lahan
</Button>
@@ -237,7 +271,9 @@ import { OfflineService } from "@/components/common/offlineStorage";
transition={{ delay: 0.2 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
{filteredLands.map((land, idx) => (
{filteredLands.map((land, idx) => {
if(!land) return null;
return (
<motion.div
key={land.id}
initial={{ opacity: 0, y: 20 }}
@@ -247,10 +283,11 @@ import { OfflineService } from "@/components/common/offlineStorage";
<LandCard
land={land}
farmerName={farmerMap[land.profile_id]}
lastInspection={land.inspections?.[0]}
plantCount={plants.filter(p => p.lahan.id === land.id).length}
/>
</motion.div>
))}
)})}
</motion.div>
) : (
<EmptyState
+57 -17
View File
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import EmptyState from "@/components/common/EmptyState";
import {
@@ -20,6 +20,7 @@ import { toast } from "sonner";
import { motion } from "framer-motion";
import { format } from "date-fns";
import { id } from "date-fns/locale";
import { entity } from "@/api/entities";
const gradeConfig = {
A: { label: "Grade A", color: "bg-emerald-100 text-emerald-700", description: "Kualitas Premium" },
@@ -41,12 +42,17 @@ export default function OfftakerPortal() {
useEffect(() => {
const loadData = async () => {
try {
const currentUser = await base44.auth.me();
const resp = await entity("auth", 'me').list();
const currentUser = resp?.data || null;
console.log("Current User:", currentUser);
setUser(currentUser);
const offtakers = await base44.entities.Offtaker.filter({ user_email: currentUser.email });
if (offtakers.length > 0 && offtakers[0].is_active) {
setOfftaker(offtakers[0]);
const offtakers = await entity("map","offtaker").find({ user_email: currentUser.email });
// const offtakers = await entity("auth", "profile").list({ user_email: currentUser.email });
const filteredOfftakers = offtakers?.data ? offtakers.data : [];
if (filteredOfftakers && filteredOfftakers[0].is_active) {
setOfftaker(filteredOfftakers[0]);
}
} catch (error) {
// Not logged in
@@ -57,31 +63,49 @@ export default function OfftakerPortal() {
loadData();
}, []);
// Load harvests pending grading
//get pending harvests that are sssigned to this offtaker
const { data: pendingHarvests = [] } = useQuery({
queryKey: ["pending-harvests"],
queryFn: () => base44.entities.Harvest.filter({ status: "pending_grade" }),
queryFn: async() => {
if (!offtaker) return [];
const response = await entity('distribusi', 'panen').list();
const allHarvests = Array.isArray(response?.data) ? response.data : Array.isArray(response?.data?.data) ? response.data.data : [];
console.log("All Harvests:", allHarvests);
return allHarvests.filter(h => h.offtaker_grade === null && h.status === "pending");
},
enabled: !!offtaker
});
// Load graded harvests by this offtaker
const { data: gradedHarvests = [] } = useQuery({
queryKey: ["graded-harvests", offtaker?.user_email],
queryFn: () => base44.entities.Harvest.filter({ graded_by: offtaker.user_email }),
queryFn: async () => {
const response = await entity('distribusi', 'panen').list();
const allHarvests = Array.isArray(response?.data) ? response.data : Array.isArray(response?.data?.data) ? response.data.data : [];
return allHarvests.filter(h => h.offtaker_grade !== null && (h.status === "graded" || h.status === "rejected"));
},
enabled: !!offtaker
});
// Load pending distributions for this offtaker
const { data: pendingDistributions = [] } = useQuery({
queryKey: ["pending-distributions", offtaker?.id],
queryFn: () => base44.entities.Distribution.filter({ offtaker_id: offtaker.id, status: "pending" }),
queryFn: async() => {
const response = await entity('distribusi', 'panen').list();
const allDistributions = Array.isArray(response?.data) ? response.data : Array.isArray(response?.data?.data) ? response.data.data : [];
return allDistributions.filter(d => d.offtaker_id === offtaker.id && d.status === "pending");
},
enabled: !!offtaker?.id
});
// Load graded distributions
const { data: gradedDistributions = [] } = useQuery({
queryKey: ["graded-distributions", offtaker?.id],
queryFn: () => base44.entities.Distribution.filter({ offtaker_id: offtaker.id }),
queryFn: async() => {
const response = await entity('distribusi', 'panen').list();
const allHarvests = Array.isArray(response?.data) ? response.data : Array.isArray(response?.data?.data) ? response.data.data : [];
return allHarvests.filter(h => h.offtaker_grade !== null && (h.status === "graded" || h.status === "rejected"));
},
enabled: !!offtaker?.id
});
@@ -95,11 +119,14 @@ export default function OfftakerPortal() {
// Grade mutation
const gradeMutation = useMutation({
mutationFn: async () => {
const totalValue = parseFloat(gradeForm.price_per_kg) * selectedHarvest.quantity_kg;
await base44.entities.Harvest.update(selectedHarvest.id, {
const price = parseFloat(gradeForm.price_per_kg);
const totalValue = price * selectedHarvest.quantity_kg;
// Tambahkan return di sini
return await entity('distribusi','panen').update(selectedHarvest.id, {
grade: gradeForm.grade,
grade_notes: gradeForm.notes,
price_per_kg: parseFloat(gradeForm.price_per_kg),
price_per_kg: price,
total_value: totalValue,
graded_by: offtaker.user_email,
graded_by_name: offtaker.contact_name,
@@ -113,6 +140,10 @@ export default function OfftakerPortal() {
setSelectedHarvest(null);
setGradeForm({ grade: "", notes: "", price_per_kg: "" });
toast.success("Grading berhasil disimpan!");
},
onError: (error) => {
console.error("Error grading harvest:", error);
toast.error("Gagal menyimpan grading. Silakan coba lagi.");
}
});
@@ -235,7 +266,7 @@ export default function OfftakerPortal() {
{/* Tabs */}
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<Tabs defaultValue="distributions" className="space-y-4">
<TabsList className="w-full bg-white shadow-sm p-1 h-auto">
<TabsList className="w-full bg-white shadow-sm p-1 h-auto space-x-1">
<TabsTrigger value="distributions" className="flex-1 py-3 data-[state=active]:bg-indigo-50 data-[state=active]:text-indigo-700">
<Truck className="w-4 h-4 mr-2" />
Distribusi ({pendingDistributions.length})
@@ -412,6 +443,10 @@ export default function OfftakerPortal() {
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Grading Hasil Panen</DialogTitle>
{/* This fixes the warning without changing your UI design */}
<DialogDescription className="sr-only">
Silakan masukkan grading dan harga untuk hasil panen ini.
</DialogDescription>
</DialogHeader>
{selectedHarvest && (
@@ -475,9 +510,14 @@ export default function OfftakerPortal() {
</div>
<Button
className="w-full h-12 bg-indigo-600 hover:bg-indigo-700"
onClick={() => gradeMutation.mutate()}
disabled={!gradeForm.grade || !gradeForm.price_per_kg || gradeMutation.isPending}
type="button"
// className="w-full h-12 bg-indigo-600 hover:bg-indigo-700"
onClick={(e) => {
e.preventDefault();
console.log("Button Clicked!");
gradeMutation.mutate();
}}
// disabled={!gradeForm.grade || !gradeForm.price_per_kg || gradeMutation.isPending}
>
{gradeMutation.isPending ? (
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
+12 -1
View File
@@ -39,6 +39,17 @@ export default function Offtakers() {
}
});
// const {data: rawOfftakers=[], isLoading } = useQuery({
// queryKey:['offtakers'],
// queryFn: async() => {
// const res = await entity("auth", "profile").list();
// const data = res.data?.data || res.data;
// return data;
// },
// });
// const offtakers = rawOfftakers.filter(data => data?.user.role === 'offtaker');
const createMutation = useMutation({
mutationFn: (data) => base44.entities.Offtaker.create(data),
onSuccess: () => {
@@ -89,7 +100,7 @@ export default function Offtakers() {
<Label>Email *</Label>
<Input
type="email"
value={formData.user_email}
value={formData.email}
onChange={(e) => setFormData(p => ({ ...p, user_email: e.target.value }))}
placeholder="email@company.com"
/>
+30 -8
View File
@@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TreePine, Search, Filter, MapPin, Calendar, ChevronRight } from "lucide-react";
import { TreePine, Search, Filter, MapPin, Calendar, ChevronRight, Globe } from "lucide-react";
import { motion } from "framer-motion";
import { format } from "date-fns";
import { entity } from "@/api/entities";
@@ -46,9 +46,30 @@ export default function Plants() {
const { data: rawlands = [] } = useQuery({
queryKey: ["lands"],
queryFn: async() => {
const rawData = await entity('map', 'lahan').list();
const data = Array.isArray(rawData?.data) ? rawData?.data : Array.isArray(rawData?.data?.data) ? rawData?.data?.data : [];
return data;
const fetchAllLands = async() => {
let allLands = [];
let page = 1;
let hasNextPage = true;
while(hasNextPage){
const res = await entity('map', 'lahan').list({page: page});
const lands = Array.isArray(res?.data) ? res?.data : Array.isArray(res?.data?.data) ? res?.data?.data : [];
allLands = [...allLands, ...lands];
const lastPage = res?.data?.meta?.last_page || 1;
if(page >= lastPage) {
hasNextPage = false;
}else{
page++;
}
}
return allLands;
};
const dataReturned = await fetchAllLands();
return dataReturned;
}
});
@@ -157,7 +178,7 @@ export default function Plants() {
<div className="w-3 h-3 rounded-full bg-emerald-500" />
</div>
<div>
<p className="text-2xl font-bold text-slate-900">{plants.filter(p => p.status === "alive").length}</p>
<p className="text-2xl font-bold text-slate-900">{plants.filter(p => p.status === "alive" || p.kondisi_tanaman === "sehat" || p.kondisi_tanaman === null).length}</p>
<p className="text-sm text-slate-500">Hidup</p>
</div>
</div>
@@ -168,7 +189,7 @@ export default function Plants() {
<div className="w-3 h-3 rounded-full bg-amber-500" />
</div>
<div>
<p className="text-2xl font-bold text-slate-900">{plants.filter(p => p.status === "sick" || p.productivity_status === "less_productive").length}</p>
<p className="text-2xl font-bold text-slate-900">{plants.filter(p => p.status === "sick" || p.kondisi_tanaman === "sakit" || p.productivity_status === "less_productive").length}</p>
<p className="text-sm text-slate-500">Sakit</p>
</div>
</div>
@@ -294,8 +315,9 @@ export default function Plants() {
<span className="truncate">{land?.nama || land?.name || "Lahan tidak diketahui"}</span>
</div>
<div className="flex items-center gap-2 text-slate-600">
<Calendar className="w-4 h-4 text-slate-400" />
<span>{plant.created_at ? format(new Date(plant.created_at), "dd MMM yyyy") : "-"}</span>
<Globe className="w-4 h-4 text-slate-400" />
{/* <span>{plant.created_at ? format(new Date(plant.created_at), "dd MMM yyyy") : "-"}</span> */}
<span>{plant?.latitude || "-"}, {plant?.longitude || "-"}</span>
</div>
</div>
+145 -27
View File
@@ -8,12 +8,14 @@ import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
TrendingUp, TrendingDown, Map as MapIcon, TreePine, BarChart3,
AlertTriangle, CheckCircle, Leaf, Bug, Cloud, Droplets
AlertTriangle, CheckCircle, Leaf, Bug, Cloud, Droplets,
User
} from "lucide-react";
import { motion } from "framer-motion";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from "recharts";
import { OfflineService } from "@/components/common/offlineStorage";
import { entity } from "@/api/entities";
import { da } from "date-fns/locale";
const COLORS = ["#10b981", "#f59e0b", "#ef4444", "#6b7280"];
@@ -40,10 +42,18 @@ const issueLabels = {
export default function ProductivityMonitoring() {
const [filterLevel, setFilterLevel] = useState("regency");
const [selectedRegion, setSelectedRegion] = useState("all");
const [isFarmer, setIsFarmer] = useState(false);
// Reset selected region when filter level changes
useEffect(() => {
setSelectedRegion("all");
const checkStatusUser = async () => {
const check = await checkIsFarmer();
setIsFarmer(check);
}
checkStatusUser();
}, [filterLevel]);
const getMe = async() => {
@@ -52,24 +62,67 @@ export default function ProductivityMonitoring() {
return me;
};
const checkIsFarmer = async () =>{
const resp = await getMe();
return resp?.role === 'petani';
}
const {data: liveHarvests =[]} = useQuery({
queryKey: ['harvests'],
queryFn: async() => {
try{
const res = await entity('map', 'panen').list();
return res?.data || res?.data?.data || [];
}catch(err){
console.error(" Harvest Offline =>",err)
}
}
});
const {data: rawLands = []} = useQuery({
queryKey: ['lands'],
queryFn: async () => {
let me;
const fetchAllData = async () => {
let allData = [];
let currentPage = 1;
let hasNextPage = true;
while (hasNextPage) {
const res = await entity('map', 'lahan').list({ page: currentPage });
const pageData = Array.isArray(res?.data?.data) ? res.data.data : [];
allData = [...allData, ...pageData];
const lastPage = res?.data?.meta?.last_page || 1;
if (currentPage >= lastPage) {
hasNextPage = false;
} else {
currentPage++;
}
}
return allData;
};
let dataServer = [];
let localData = [];
try{
me = await getMe();
const res = await entity('map', 'lahan').list();
const data = res.data;
dataServer = Array.isArray(data) ? data.filter((land) => land.profile_id === me.id) : [];
// const res = await entity('map', 'lahan').list();
// const data = Array.isArray(res?.data) ? res.data : Array.isArray(res?.data?.data) ? res?.data?.data : [];
dataServer = me?.user?.role === 'super_admin' || me?.user?.role === 'fasilitator'
? await fetchAllData()
: await fetchAllData()
.then(data => data.filter((land) => land.profile_id === me.id));
}catch(err){
console.error(err)
}
const localRes = await OfflineService.getEntities('lands');
const localData = localRes.filter((data) => data.profile_id === me.id);
if(me.user.role === 'super_admin' || me.user.role === 'fasilitator'){
localData = localRes.filter((data) => data.profile_id === me.id);
}
const combined = new Map();
dataServer.forEach(d => {
@@ -84,7 +137,6 @@ export default function ProductivityMonitoring() {
}catch(err){
console.error(err)
}
return Array.from(combined.values());
}
});
@@ -105,9 +157,12 @@ export default function ProductivityMonitoring() {
let me;
try{
me = await getMe();
console.log(me, "Med")
const res = await entity('map', 'tanaman').list();
if(me){data = Array.isArray(res.data.data) ? res.data.data.filter((data) => data.profile_id === me.id) :[];}
const dts = Array.isArray(res?.data) ? res?.data : Array.isArray(res.data.data) ? res.data.data : []
data = me.user.role === 'super_admin' || me.user.role === 'fasilitator'
? dts
: dts.filter((dt) => dt.profile_id === me.id);
}catch(e){}
let local = await OfflineService.getEntities('plants');
local = Array.isArray(local) ? local.filter((l) => l.profile_id === me.id) : [];
@@ -128,11 +183,18 @@ export default function ProductivityMonitoring() {
const { data: rawHarvests = [] } = useQuery({
queryKey: ["harvests"],
queryFn: async () => {
let me;
try {
me = await getMe();
const res = await entity('map', 'panen').list();
let harvests = res?.data || res?.data?.data || [];
return Array.isArray(harvests) ? harvests.filter((hr) => hr.profile_id === getMe.id) : [];
let returnData = me.user.role === 'super_admin' || me.user.role === 'fasilitator'
? harvests
: harvests.filter((hr) => hr.profile_id === me.id);
return returnData;
} catch (e) {
console.error("Error => ", e);
return [];
}
}
@@ -167,14 +229,14 @@ export default function ProductivityMonitoring() {
? lands
: filterLevel === "village"
? lands.filter(l => {
const villageName = l.village || l.village_name || l.desa || l.village_id;
const villageId = l.village_id || l.village;
const villageName = l.village || l.village_name || l.desa || l.village_id || l.desa_kelurahan.nama;
const villageId = l.village_id || l.village || l.desa_kelurahan.id;
return villageName === selectedRegion || villageId === selectedRegion;
})
: lands.filter(l => l[filterLevel] === selectedRegion);
const filteredLandIds = filteredLands.map(l => l.id);
const filteredPlants = plants.filter(p => filteredLandIds.includes(p.land_id || p.lahan_id));
const filteredLandIds = filteredLands.length > 0 ? filteredLands.map(l => l.id) : [];
const filteredPlants = plants.length > 0 ? plants.filter(p => filteredLandIds.includes(p?.lahan?.id || p?.lahan_id)) : [];
const calculateProductivityStats = () => {
const totalLands = filteredLands.length;
@@ -183,11 +245,22 @@ export default function ProductivityMonitoring() {
const landsWithProductivity = filteredLands.filter(l => l.productivity_percentage !== undefined);
const landBasedAvg = landsWithProductivity.length > 0
? landsWithProductivity.reduce((sum, l) => sum + (l.productivity_percentage || 0), 0) / landsWithProductivity.length
: 0;
: 10;
// Calculate harvest-based productivity
const harvestProductivity = calculateHarvestBasedProductivity();
const landsWithCalculatedProductivity = filteredLands.map(land => {
// Cari apakah lahan ini ada di dalam data panen
const harvestData = harvestProductivity.landProductivity.find(
hp => String(hp.landId) === String(land.id)
);
return {
...land,
productivity_percentage: harvestData ? harvestData.productivityPercentage : 10
};
});
// Combined average (weighted)
const combinedAvg = landsWithProductivity.length > 0 && harvestProductivity.totalHarvests > 0
? (landBasedAvg + harvestProductivity.avg) / 2
@@ -196,10 +269,10 @@ export default function ProductivityMonitoring() {
: harvestProductivity.avg;
const distribution = [
{ name: "Sangat Produktif (>80%)", value: filteredLands.filter(l => (l.productivity_percentage || 0) > 80).length, color: "#10b981" },
{ name: "Produktif (60-80%)", value: filteredLands.filter(l => (l.productivity_percentage || 0) >= 60 && (l.productivity_percentage || 0) <= 80).length, color: "#22c55e" },
{ name: "Kurang Produktif (40-60%)", value: filteredLands.filter(l => (l.productivity_percentage || 0) >= 40 && (l.productivity_percentage || 0) < 60).length, color: "#f59e0b" },
{ name: "Tidak Produktif (<40%)", value: filteredLands.filter(l => (l.productivity_percentage || 0) < 40).length, color: "#ef4444" }
{ name: "Sangat Produktif (>80%)", value: landsWithCalculatedProductivity.filter(l => (l.productivity_percentage || 0) > 80).length, color: "#10b981" },
{ name: "Produktif (60-80%)", value: landsWithCalculatedProductivity.filter(l => (l.productivity_percentage || 0) >= 60 && (l.productivity_percentage || 0) <= 80).length, color: "#22c55e" },
{ name: "Kurang Produktif (40-60%)", value: landsWithCalculatedProductivity.filter(l => (l.productivity_percentage || 0) >= 40 && (l.productivity_percentage || 0) < 60).length, color: "#f59e0b" },
{ name: "Tidak Produktif (<40%)", value: landsWithCalculatedProductivity.filter(l => (l.productivity_percentage || 0) < 40).length, color: "#ef4444" }
];
return {
@@ -213,7 +286,7 @@ export default function ProductivityMonitoring() {
// Calculate harvest-based productivity
const calculateHarvestBasedProductivity = () => {
const filteredHarvests = harvests.filter(h => filteredLandIds.includes(h.land_id));
const filteredHarvests = rawHarvests.filter(h => filteredLandIds.includes(h.land_id));
if (filteredHarvests.length === 0) {
return { avg: 0, totalHarvests: 0, totalWeight: 0, landProductivity: [] };
@@ -224,7 +297,7 @@ export default function ProductivityMonitoring() {
if (!acc[harvest.land_id]) {
acc[harvest.land_id] = { totalWeight: 0, harvestCount: 0 };
}
acc[harvest.land_id].totalWeight += harvest.weight_kg || 0;
acc[harvest.land_id].totalWeight += harvest.weight_kg || 10;
acc[harvest.land_id].harvestCount++;
return acc;
}, {});
@@ -290,16 +363,34 @@ export default function ProductivityMonitoring() {
// Calculate regional productivity
const calculateRegionalProductivity = () => {
const groupKey = filterLevel === "regency" ? "regency" : filterLevel === "district" ? "district" : "village";
const getRegionName = (land) => {
switch(filterLevel){
case "regency":
return land?.desa_kelurahan?.kecamatan?.kabupatenKota?.nama || 'N/A';
case "district":
return land?.desa_kelurahan?.kecamatan?.nama || 'N/A';
case "village":
return land?.desa_kelurahan?.nama || 'N/A';
default:
console.log("Unknown filter level, defaulting to Regency");
}
}
const grouped = lands.reduce((acc, land) => {
const region = land[groupKey] || "Tidak Diketahui";
// const region = land[groupKey] || "Tidak Diketahui";
const region = getRegionName(land) || "Tidak Diketahui";
if (!acc[region]) {
acc[region] = { totalLands: 0, totalArea: 0, totalProductivity: 0, landsWithProductivity: 0 };
}
acc[region].totalLands++;
acc[region].totalArea += land.area_hectares || 0;
acc[region].totalArea += parseFloat(land.luas_lahan) || 0;
if (land.productivity_percentage !== undefined) {
acc[region].totalProductivity += land.productivity_percentage;
acc[region].totalProductivity += land.productivity_percentage || 10;
acc[region].landsWithProductivity++;
}
else{
acc[region].totalProductivity += land.productivity_percentage || 10;
acc[region].landsWithProductivity++;
}
return acc;
@@ -331,9 +422,36 @@ export default function ProductivityMonitoring() {
name: grade === "rejected" ? "Rejected" : `Grade ${grade}`,
value: gradeDistribution[grade] || 0
}));
// if(!isFarmer){
// return (
// <div className="min-h-full flex items-center justify-center p-6">
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// >
// <Card className="border-0 shadow-xl max-w-md w-full">
// <CardContent className="p-8 text-center">
// <div className="w-20 h-20 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-6">
// <User className="w-10 h-10 text-amber-600" />
// </div>
// <h1 className="text-2xl font-bold text-slate-900 mb-2">Akun Belum Terdaftar</h1>
// <p className="text-slate-500 mb-4">
// belum terhubung dengan data petani.
// </p>
// <p className="text-sm text-slate-400 mb-8">
// Hubungi admin untuk mendaftarkan akun Anda sebagai petani.
// </p>
// </CardContent>
// </Card>
// </motion.div>
// </div>
// );
// }
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
// <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="min-h-full">
<div className="max-w-7xl mx-auto p-6 space-y-6">
{/* Header */}
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="flex flex-col md:flex-row md:items-center justify-between gap-4">
@@ -457,7 +575,7 @@ export default function ProductivityMonitoring() {
<div className="flex-1">
<p className="text-sm text-slate-500">Produktivitas Panen</p>
<p className="text-lg font-bold text-slate-900">{productivityStats.harvestBasedAvg.toFixed(1)}%</p>
<p className="text-xs text-slate-400">{productivityStats.harvestDetails?.totalWeight || 0}kg dari {productivityStats.harvestDetails?.totalHarvests || 0} panen</p>
<p className="text-xs text-slate-400">{productivityStats.harvestDetails?.totalWeight || 10}kg dari {productivityStats.harvestDetails?.totalHarvests || 10} panen</p>
</div>
</div>
</CardContent>
@@ -470,7 +588,7 @@ export default function ProductivityMonitoring() {
</div>
<div className="flex-1">
<p className="text-sm text-slate-500">Rata-rata Hasil/Ha</p>
<p className="text-lg font-bold text-slate-900">{productivityStats.harvestDetails?.overallYieldPerHa?.toFixed(0) || 0} kg</p>
<p className="text-lg font-bold text-slate-900">{productivityStats.harvestDetails?.overallYieldPerHa?.toFixed(0) || 10} kg</p>
<p className="text-xs text-slate-400">Total produktivitas per hektar</p>
</div>
</div>
+1 -1
View File
@@ -209,7 +209,7 @@ export default function ValidatorPortal() {
</div>
<Tabs defaultValue="lands" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-3 space-x-[2px]">
<TabsTrigger value="lands">Validasi Lahan</TabsTrigger>
<TabsTrigger value="farmers">Validasi Petani</TabsTrigger>
<TabsTrigger value="history">Riwayat</TabsTrigger>
+11 -11
View File
@@ -42,9 +42,10 @@ export default function Validators() {
queryKey: ["provinces"],
queryFn: async () => {
try{
const data = base44.entities.Province.list();
const data = await entity('master','provinsi').list();
const dataArray = data?.data?.data || data?.data || [];
if (data && Array.isArray(data)) {
return data;
return dataArray;
}
return [];
}catch(e){
@@ -58,9 +59,9 @@ export default function Validators() {
queryKey: ["validators"],
queryFn: async () => {
try{
const res = await entity('manajemen', 'fasilitator').list();
console.log(res.data.data)
return Array.isArray(res?.data) ? res?.data : Array.isArray(res?.data.data) ? res.data.data : [];
const res = await entity('auth', 'profile').list();
const ret =Array.isArray(res?.data) ? res?.data : Array.isArray(res?.data.data) ? res.data.data : [];
return ret.filter(data => data.user?.role === 'fasilitator');
}catch(ex){
return [];
}
@@ -86,7 +87,7 @@ export default function Validators() {
if(typeof actServer === 'string' && actServer.includes('<!doctype html')){
throw new Error('Data is HTML file, not a good responds');
}
return actServer;
return actServer.data?.data || actServer?.data;
}catch(err){
throw {type: 'OFFLINE_SAVE', data}
}
@@ -144,7 +145,7 @@ export default function Validators() {
<Label>Email *</Label>
<Input
type="email"
value={formData.user_email}
value={formData.user_email || formData.email}
onChange={(e) => setFormData(p => ({ ...p, user_email: e.target.value }))}
placeholder="email@example.com"
/>
@@ -249,8 +250,8 @@ export default function Validators() {
<Shield className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-semibold text-slate-900">{v.full_name}</p>
<Badge className="bg-blue-100 text-blue-700 text-xs">{roleLabels[v.role]}</Badge>
<p className="font-semibold text-slate-900">{v.full_name || v.nama}</p>
<Badge className="bg-blue-100 text-blue-700 text-xs">{v.user?.role}</Badge>
</div>
</div>
<Switch
@@ -260,8 +261,7 @@ export default function Validators() {
</div>
<div className="space-y-2 text-sm text-slate-600">
<p className="flex items-center gap-2"><MapPin className="w-4 h-4" />
{v.village}, {v.district}, {v.regency},
{provinces.find(p => p.id === v.province)?.nama}
{v.alamat || '......'}
</p>
{v.phone && <p className="flex items-center gap-2"><Phone className="w-4 h-4" /> {v.phone}</p>}
<p className="text-xs text-slate-400">{v.user_email}</p>
+1
View File
@@ -15,6 +15,7 @@ db.version(1).stores({
profiles: 'id,user_id, kk,ktp,email, sync_status, name, nama',
harvests: 'id, plant_id, land_id,farmer_id, sync_status',
plant_inspections: 'id, plant_id, land_id, farmer_id, sync_status',
land_inspections: 'id, land_id, farmer_id, sync_status',
distributions: 'id, farmer_id,offtaker_id, sync_status',
villages: 'id, name',
districts: 'id, name',
+121
View File
@@ -0,0 +1,121 @@
export const moodConfig = {
// 1. PURPLE (Dark Sidebar)
purple: {
main: "text-purple-400",
bgActive: "bg-purple-700/50",
textActive: "text-white",
shadow: "shadow-purple-900/40",
iconActive: "text-purple-200",
sidebarBg: "bg-[#4c0b88]",
// contentBg: "bg-[#fcfaff]",
contentBg: "bg-purple-100",
borderSide: "border-purple-800",
navText: "text-purple-200/70",
navTextHover: "hover:text-white hover:bg-purple-800/50",
accent: "bg-purple-600",
accentHover: "hover:bg-purple-700",
accentText: "text-purple-600"
},
// 2. EMERALD (Classic Nature)
emerald: {
main: "text-emerald-600",
bgActive: "bg-emerald-50",
textActive: "text-emerald-700",
shadow: "shadow-emerald-500/25",
iconActive: "text-emerald-600",
sidebarBg: "bg-white",
contentBg: "bg-slate-50",
borderSide: "border-slate-200",
navText: "text-slate-600",
navTextHover: "hover:bg-slate-50 hover:text-slate-900",
accent: "bg-emerald-600",
accentHover: "hover:bg-emerald-700",
accentText: "text-emerald-600"
},
// 3. OCEAN BLUE (Professional & Trust)
blue: {
main: "text-blue-100", // Digunakan untuk icon/chevron saat active di sidebar
bgActive: "bg-blue-700", // Background item aktif (lebih gelap dari sidebar agar kontras)
textActive: "text-white",
shadow: "shadow-blue-900/30",
iconActive: "text-white",
sidebarBg: "bg-[#348ceb]", // Warna utama keinginan kamu
contentBg: "bg-blue-100", // Biru sangat muda agar konten tetap bersih
borderSide: "border-blue-400/50", // Border tipis yang senada
navText: "text-blue-50", // Teks menu saat tidak aktif
navTextHover: "hover:text-white hover:bg-blue-500/50",
accent: "bg-[#348ceb]", // Warna tombol utama
accentHover: "hover:bg-blue-600",
accentText: "text-blue-600"
},
// 4. HARVEST ORANGE (Warm & Active)
orange: {
main: "text-orange-600",
bgActive: "bg-orange-50",
textActive: "text-orange-700",
shadow: "shadow-orange-500/20",
iconActive: "text-orange-600",
sidebarBg: "bg-white",
contentBg: "bg-orange-100",
borderSide: "border-orange-100",
navText: "text-slate-600",
navTextHover: "hover:bg-orange-50 hover:text-orange-900",
accent: "bg-orange-500",
accentHover: "hover:bg-orange-600",
accentText: "text-orange-600"
},
// 5. MIDNIGHT FOREST (Elegant Dark Green)
forest: {
main: "text-emerald-400",
bgActive: "bg-emerald-800/40",
textActive: "text-white",
shadow: "shadow-black/20",
iconActive: "text-emerald-300",
sidebarBg: "bg-[#064e3b]", // Very Dark Green
contentBg: "bg-slate-100",
borderSide: "border-emerald-900",
navText: "text-emerald-100/60",
navTextHover: "hover:text-white hover:bg-emerald-800/60",
accent: "bg-[#065f46]",
accentHover: "hover:bg-[#047857]",
accentText: "text-[#065f46]"
},
// 6. ROSE (Modern & Soft)
rose: {
main: "text-rose-600",
bgActive: "bg-rose-50",
textActive: "text-rose-700",
shadow: "shadow-rose-500/20",
iconActive: "text-rose-600",
sidebarBg: "bg-white",
contentBg: "bg-rose-100",
borderSide: "border-rose-100",
navText: "text-slate-600",
navTextHover: "hover:bg-rose-50 hover:text-rose-900",
accent: "bg-rose-500",
accentHover: "hover:bg-rose-600",
accentText: "text-rose-600"
},
// 7. SLATE (Minimalist/Enterprise)
slate: {
main: "text-slate-900",
bgActive: "bg-slate-900",
textActive: "text-white",
shadow: "shadow-slate-400/20",
iconActive: "text-white",
sidebarBg: "bg-slate-100", // Light Grey Sidebar
contentBg: "bg-white",
borderSide: "border-slate-200",
navText: "text-slate-500",
navTextHover: "hover:bg-slate-200 hover:text-slate-900",
accent: "bg-slate-800",
accentHover: "hover:bg-slate-900",
accentText: "text-slate-800"
}
};
+13
View File
@@ -35,6 +35,19 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
build: {
// PENTING: Targetkan ke browser yang lebih modern agar Map/Set tidak diubah menjadi kode aneh
target: ['es2020', 'ios13'],
// Mematikan minify sementara bisa membantu memastikan apakah ini bug minifikasi
minify: 'terser',
terserOptions: {
compress: {
// Mencegah terser merusak konstruktor seperti Map
keep_fnames: true,
keep_classnames: true,
},
},
},
server: {
port: 5173,
open: true,