new api
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../skhp"
|
||||
},
|
||||
{
|
||||
"path": "../dev/clipflow"
|
||||
},
|
||||
{
|
||||
"path": "../dev/front-clipflow"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
|
||||
Executable
+252
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([])}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+59
@@ -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;
|
||||
Executable
+252
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user