new api
This commit is contained in:
@@ -107,6 +107,7 @@
|
|||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"terser": "^5.46.2",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../skhp"
|
"path": "../skhp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../dev/clipflow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../dev/front-clipflow"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {}
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+256
-158
@@ -1,147 +1,172 @@
|
|||||||
|
import { moodConfig } from "@/utils/moodColor";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Map, TreePine,
|
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";
|
} from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { OfflineService, isOnline as checkIsOnline } from "@/components/common/offlineStorage";
|
import { OfflineService, isOnline as checkIsOnline } from "@/components/common/offlineStorage";
|
||||||
import {useAuth} from '@/lib/AuthContext';
|
import {useAuth} from '@/lib/AuthContext';
|
||||||
|
import SmartPopup from '@/components/ui/smartpopup';
|
||||||
|
|
||||||
function SyncIndicator() {
|
async function checkInternet() {
|
||||||
const [online, setOnline] = useState(checkIsOnline());
|
if (!navigator.onLine) return false;
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
useEffect(() => {
|
setTimeout(() => controller.abort(), 3000);
|
||||||
let mounted = true;
|
await fetch("https://www.google.com/favicon.ico", {
|
||||||
|
method: "HEAD",
|
||||||
const updateStatus = async () => {
|
mode: "no-cors",
|
||||||
const isConnected = await checkInternet();
|
cache: "no-store",
|
||||||
const count = await OfflineService.getPendingCount();
|
signal: controller.signal,
|
||||||
|
});
|
||||||
if (mounted) {
|
return true;
|
||||||
setOnline(isConnected);
|
} catch {
|
||||||
setPendingCount(count);
|
return false;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
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 wikiConfig = JSON.parse(import.meta.env.VITE_MEDIA_WIKI_URL || "[]");
|
||||||
|
|
||||||
const navigation = [
|
const RawNavigation = [
|
||||||
{ name: "Dashboard", page: "Dashboard", icon: LayoutDashboard , roles: ['super_admin','fasilitator']},
|
{ name: "Dashboard", page: "Dashboard", icon: LayoutDashboard , roles: ['super_admin','fasilitator']},
|
||||||
{ name: "Petani", page: "Farmers", icon: Users, 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: "Lahan", page: "Lands", icon: Map, roles: ['super_admin','fasilitator'] },
|
||||||
{ name: "Tanaman", page: "Plants", icon: TreePine, 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: "Produktivitas", page: "ProductivityMonitoring", icon: LayoutDashboard, roles: ['super_admin', 'petani','fasilitator'] },
|
||||||
{ name: "Facilitator", page: "Validators", icon: Shield, roles: ['super_admin','fasilitator'] },
|
{ name: "Facilitator", page: "Validators", icon: Shield, roles: ['super_admin','fasilitator'] },
|
||||||
{ name: "Offtaker", page: "Offtakers", icon: Users, 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 Petani", page: "FarmerPortal", icon: Users, roles: ['super_admin', 'petani','fasilitator'] },
|
||||||
{ name: "Portal Validator", page: "ValidatorPortal", icon: Shield, roles: ['super_admin','fasilitator'] },
|
{ name: "Portal Validator", page: "ValidatorPortal", icon: Shield, roles: ['super_admin','fasilitator'] },
|
||||||
{ name: "Portal Offtaker", page: "OfftakerPortal", icon: Users, 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'] },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Layout({ children, currentPageName }) {
|
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;
|
||||||
|
|
||||||
|
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 && <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={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" : currentTheme.accentText)}>
|
||||||
|
{!online ? "Offline" : isSyncPending ? "Online (Sinkronisasi...)" : "Online"}
|
||||||
|
</p>
|
||||||
|
<p className={cn("text-xs truncate opacity-80", currentTheme.accentText)}>
|
||||||
|
{!online ? "Data disimpan lokal" : isSyncPending ? `${pendingCount} data diproses` : "Data sinkron"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ children, currentPageName, colorMood = "emerald" }) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
// if (!user) {
|
// Ambil konfigurasi warna berdasarkan mood yang dipilih
|
||||||
// return <div className="flex h-screen items-center justify-center">Memuat Sesi...</div>;
|
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) => {
|
const filteredNavigation = navigation.filter((item) => {
|
||||||
if (!user?.role) return false;
|
if (!user?.role) return false;
|
||||||
return item.roles.includes(user.role);
|
return item.roles.includes(user.role);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isActivePage = (pageName) => {
|
const isActivePage = (pageName) => {
|
||||||
|
if(!currentPageName || !pageName) return false;
|
||||||
return currentPageName === pageName || location.pathname.includes(pageName.toLowerCase());
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
<div
|
||||||
{/* Mobile Header */}
|
className={cn("min-h-screen transition-colors duration-500", currentTheme.contentBg, "[&_button]:bg-[var(--primary-app)] [&_button]:text-white hover:[&_button]:opacity-90")}
|
||||||
<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">
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" onClick={() => setSidebarOpen(true)}>
|
<Button variant="ghost" size="icon" onClick={() => setSidebarOpen(true)}>
|
||||||
<Menu className="w-6 h-6" />
|
<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>
|
<span className="font-bold text-slate-900">AgroMonev</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Sidebar Overlay */}
|
{/* Mobile Sidebar Overlay */}
|
||||||
@@ -166,63 +248,76 @@ export default function Layout({ children, currentPageName }) {
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className={cn(
|
<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",
|
"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="p-6">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br flex items-center justify-center shadow-lg shadow-emerald-500/25">
|
{/* SHADOW DYNAMIC */}
|
||||||
{/* <TreePine className="w-6 h-6 text-white" /> */}
|
<div className={cn(
|
||||||
<img src="/logo1.png" alt="" />
|
"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>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-bold text-slate-900">AgroMonev</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{/* ... tombol close mobile ... */}
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="lg:hidden"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
{filteredNavigation.filter((n) => n.roles.includes(user.role)).map((item) => {
|
{filteredNavigation.map((item) => {
|
||||||
const isActive = isActivePage(item.page);
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.page}
|
key={item.page}
|
||||||
to={createPageUrl(item.page)}
|
to={createPageUrl(item.page)}
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group",
|
"flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group",
|
||||||
isActive
|
isActive
|
||||||
? "bg-emerald-50 text-emerald-700"
|
? `${currentTheme.bgActive} ${currentTheme.textActive}`
|
||||||
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
: `${currentTheme.navText} ${currentTheme.navTextHover}` // Perubahan di sini
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className={cn(
|
<item.icon className={cn(
|
||||||
"w-5 h-5 transition-colors",
|
"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>
|
<span className="font-medium">{item.name}</span>
|
||||||
{isActive && (
|
</Link>
|
||||||
<ChevronRight className="w-4 h-4 ml-auto text-emerald-600" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sync Status */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-100">
|
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-100">
|
||||||
<div className="p-4 space-y-2">
|
<div className="p-4 space-y-2">
|
||||||
{/* Tombol Logout */}
|
{/* 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"
|
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" />
|
<LogOut className={cn("w-5 h-5 text-red-500 group-hover:text-red-500")} />
|
||||||
<span className="font-medium">Keluar Sesi</span>
|
<span className={cn("font-medium text-red-500")}>Keluar Sesi</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Indikator Online */}
|
{/* Indikator Online */}
|
||||||
@@ -243,10 +338,13 @@ export default function Layout({ children, currentPageName }) {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
<main className={cn(
|
||||||
<main className="lg:ml-64 pt-16 lg:pt-0 min-h-screen">
|
"mt-8 lg:ml-64 pt-16 lg:pt-0 min-h-screen transition-all",
|
||||||
|
currentTheme.contentBg // Memastikan area konten mengikuti mood
|
||||||
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
<SmartPopup/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,13 +15,16 @@ export function useSyncManager() {
|
|||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
try {
|
try {
|
||||||
const hasPending = await OfflineService.hasPendingData();
|
const hasPending = await OfflineService.hasPendingData();
|
||||||
|
// checking if has pending data
|
||||||
if (hasPending) {
|
if (hasPending) {
|
||||||
await OfflineService.syncAllPending();
|
await OfflineService.syncAllPending();
|
||||||
toast.success("Sinkronisasi data berhasil");
|
toast.success("Sinkronisasi data berhasil");
|
||||||
} else {
|
}
|
||||||
|
// othewise resync all data from local to server and server to local
|
||||||
|
else {
|
||||||
// await OfflineService.downloadFromServer();
|
// await OfflineService.downloadFromServer();
|
||||||
await OfflineService.syncDataFromServer();
|
// await OfflineService.syncDataFromServer();
|
||||||
|
await OfflineService.reconcileAndPush();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync process failed:', 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.plants.where('sync_status').equals('pending').count(),
|
||||||
db.harvests.where('sync_status').equals('pending').count(),
|
db.harvests.where('sync_status').equals('pending').count(),
|
||||||
db.plant_inspections.where('sync_status').equals('pending').count(),
|
db.plant_inspections.where('sync_status').equals('pending').count(),
|
||||||
|
db.land_inspections.where('sync_status').equals('pending').count(),
|
||||||
db.offtakers.where('sync_status').equals('pending').count(),
|
db.offtakers.where('sync_status').equals('pending').count(),
|
||||||
db.profiles.where('sync_status').equals('pending').count(),
|
db.profiles.where('sync_status').equals('pending').count(),
|
||||||
db.distributions.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.lands, to:"lahan", endpoint: 'map/lahan' },
|
||||||
{ from: db.plants, to:"tanaman", endpoint: 'map/tanaman' },
|
{ from: db.plants, to:"tanaman", endpoint: 'map/tanaman' },
|
||||||
{ from: db.harvests, to:"panen", endpoint: 'map/panen'},
|
{ 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.offtakers, to:"offtaker", endpoint: 'map/offtaker'},
|
||||||
{ from: db.distributions, to:"panen", endpoint: 'distribusi/panen'},
|
{ from: db.distributions, to:"panen", endpoint: 'distribusi/panen'},
|
||||||
{ from: db.villages, to: "desa-kelurahan", endpoint: "master/desa-kelurahan" },
|
{ from: db.villages, to: "desa-kelurahan", endpoint: "master/desa-kelurahan" },
|
||||||
@@ -146,13 +148,13 @@ export const OfflineService = {
|
|||||||
for (const config of syncConfigs) {
|
for (const config of syncConfigs) {
|
||||||
try {
|
try {
|
||||||
console.log(`Syncing table: ${config.from.name}...`);
|
console.log(`Syncing table: ${config.from.name}...`);
|
||||||
|
// getting data from server
|
||||||
const response = await axios.get(`${baseURL}/api/${config.endpoint}`, {
|
const response = await axios.get(`${baseURL}/api/${config.endpoint}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
console.log(response, "Res")
|
// check respond status
|
||||||
if (response.status !== 200) throw new Error(`HTTP error! status: ${response.status}`);
|
if (response.status !== 200) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
// take respond data
|
||||||
const result = await response;
|
const result = await response;
|
||||||
const dataToSave = config.to ? result[config.to] : (result.data || result.data.data || []);
|
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/lahan', table: db.lands },
|
||||||
{ endpoint: 'map/tanaman', table: db.plants },
|
{ endpoint: 'map/tanaman', table: db.plants },
|
||||||
{ endpoint: 'map/panen', table: db.harvests},
|
{ 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: 'map/offtaker', table: db.offtakers},
|
||||||
{ endpoint: 'distribusi/panen', table: db.distributions},
|
{ endpoint: 'distribusi/panen', table: db.distributions},
|
||||||
// { endpoint: 'map/validator', table: db.validators}
|
// { endpoint: 'map/validator', table: db.validators}
|
||||||
@@ -255,11 +258,12 @@ export const OfflineService = {
|
|||||||
},
|
},
|
||||||
syncAllPending: async () => {
|
syncAllPending: async () => {
|
||||||
const tables = [
|
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.lands, to:"lahan", endpoint: 'map/lahan' },
|
||||||
{ from: db.plants, to:"tanaman", endpoint: 'map/tanaman' },
|
{ from: db.plants, to:"tanaman", endpoint: 'map/tanaman' },
|
||||||
{ from: db.harvests, to:"panen", endpoint: 'map/panen'},
|
{ 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.offtakers, to:"offtaker", endpoint: 'map/offtaker'},
|
||||||
{ from: db.distributions, to:"panen", endpoint: 'distribusi/panen'},
|
{ from: db.distributions, to:"panen", endpoint: 'distribusi/panen'},
|
||||||
// { endpoint: 'map/validator', to:"validator", from: db.validators}
|
// { endpoint: 'map/validator', to:"validator", from: db.validators}
|
||||||
@@ -271,25 +275,40 @@ export const OfflineService = {
|
|||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
const baseURL = import.meta.env.VITE_BASE44_API_URL.replace(/\/+$/, "");
|
const baseURL = import.meta.env.VITE_BASE44_API_URL.replace(/\/+$/, "");
|
||||||
|
|
||||||
let hasChanged = false;
|
let hasChanged = false;
|
||||||
|
|
||||||
for (const item of tables) {
|
for (const item of tables) {
|
||||||
|
//get pending data
|
||||||
const items = await item.from.where('sync_status').equals('pending').toArray();
|
const items = await item.from.where('sync_status').equals('pending').toArray();
|
||||||
|
|
||||||
for (const record of items) {
|
for (const record of items) {
|
||||||
try {
|
try {
|
||||||
|
// data preparation
|
||||||
const { sync_status, ...dataToPush } = record;
|
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) {
|
if (dataToPush.luas_lahan) {
|
||||||
dataToPush.luas_lahan = parseFloat(parseFloat(dataToPush.luas_lahan).toFixed(2));
|
dataToPush.luas_lahan = parseFloat(parseFloat(dataToPush.luas_lahan).toFixed(2));
|
||||||
}
|
}
|
||||||
|
//try to push
|
||||||
const response = await axios.post(`${baseURL}/api/${item.endpoint}`, dataToPush, {
|
const response = await axios.post(`${baseURL}/api/${item.endpoint}`, dataToPush, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
|
//check respond from server
|
||||||
if (response.status === 200 || response.status === 201) {
|
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;
|
hasChanged = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -305,6 +324,58 @@ export const OfflineService = {
|
|||||||
await OfflineService.downloadFromServer();
|
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) => {
|
cacheMasterData: async (villages) => {
|
||||||
if (villages.length > 0) {
|
if (villages.length > 0) {
|
||||||
await db.master_villages.clear();
|
await db.master_villages.clear();
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { MapPin, Phone, Users, ChevronRight } from "lucide-react";
|
import { MapPin, Phone, Users, ChevronRight } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { entity } from "@/api/entities";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
pending: "bg-amber-100 text-amber-700 border-amber-200",
|
pending: "bg-amber-100 text-amber-700 border-amber-200",
|
||||||
@@ -18,7 +21,13 @@ const statusLabels = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function FarmerCard({ farmer }) {
|
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 (
|
return (
|
||||||
<Card className="border-0 shadow-sm hover:shadow-md transition-all p-5 bg-white group">
|
<Card className="border-0 shadow-sm hover:shadow-md transition-all p-5 bg-white group">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
@@ -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" />
|
<img src={farmer.photo_url} alt={farmer?.name || farmer?.nama || '-'} className="w-full h-full rounded-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xl font-bold text-emerald-700">
|
<span className="text-xl font-bold text-emerald-700">
|
||||||
{farmer.full_name?.charAt(0)?.toUpperCase() || "P"}
|
{farmer.full_name?.charAt(0)?.toUpperCase() || "N/A"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<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>
|
<p className="text-sm text-slate-500">NIK: {farmer?.kk}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={`${statusColors[farmer.user.email_verified_at !== null || farmer.user.email_verified_at !== undefined ? 'verified' : 'pending']} border font-medium text-xs`}>
|
<Switch
|
||||||
{statusLabels[farmer.user.email_verified_at !== null || farmer.user.email_verified_at !== undefined ? 'verified' : 'pending']}
|
checked={farmer?.user?.email_verified_at}
|
||||||
</Badge>
|
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>
|
||||||
|
|
||||||
<div className="space-y-1.5 text-sm text-slate-600">
|
<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 }) {
|
export default function FarmerForm({ initialData, onSubmit, isLoading }) {
|
||||||
|
const [locationData, setLocationData ] = useState({
|
||||||
|
provinces: [],
|
||||||
|
regencies: [],
|
||||||
|
districts: [],
|
||||||
|
villages: []
|
||||||
|
});
|
||||||
const [formData, setFormData] = useState(initialData || {
|
const [formData, setFormData] = useState(initialData || {
|
||||||
nik: "",
|
ktp: "",
|
||||||
full_name: "",
|
kk:"",
|
||||||
phone: "",
|
nama: "",
|
||||||
|
email:"",
|
||||||
|
desa_kelurahan_id:'',
|
||||||
|
telepon: "",
|
||||||
|
alamat: "",
|
||||||
farmer_group: "",
|
farmer_group: "",
|
||||||
village: "",
|
village: "",
|
||||||
district: "",
|
district: "",
|
||||||
regency: "",
|
regency: "",
|
||||||
province: "",
|
province: "",
|
||||||
verification_status: "pending",
|
verification_status: "pending",
|
||||||
sync_status: "pending"
|
sync_status: "pending",
|
||||||
|
kecamatan_id:"",
|
||||||
|
kabkota: "",
|
||||||
|
provinsi:"",
|
||||||
|
password:"",
|
||||||
|
password_confirmation: ""
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: rawProvinces } = useQuery({
|
const { data: rawProvinces } = useQuery({
|
||||||
@@ -71,6 +86,77 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
|
|||||||
}
|
}
|
||||||
}, [provinces]);
|
}, [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 (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
@@ -81,13 +167,66 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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="district">E-mail</Label>
|
||||||
|
<Input
|
||||||
|
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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="nik">NIK (Nomor Induk Kependudukan)</Label>
|
<Label htmlFor="ktp">NIK (Nomor Induk Kependudukan)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="nik"
|
id="ktp"
|
||||||
value={formData.nik}
|
value={formData.nik}
|
||||||
onChange={(e) => handleChange("nik", e.target.value)}
|
onChange={(e) => handleChange("ktp", e.target.value)}
|
||||||
placeholder="16 digit NIK"
|
placeholder="16 digit NIK"
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
required
|
required
|
||||||
@@ -95,6 +234,18 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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>
|
<Label htmlFor="full_name">Nama Lengkap</Label>
|
||||||
<Input
|
<Input
|
||||||
id="full_name"
|
id="full_name"
|
||||||
@@ -104,17 +255,31 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
|
|||||||
required
|
required
|
||||||
className="h-11"
|
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>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">Nomor Telepon</Label>
|
<Label htmlFor="telepon">Nomor Telepon</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
<Phone className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="telepon"
|
||||||
value={formData.phone}
|
value={formData.telepon}
|
||||||
onChange={(e) => handleChange("phone", e.target.value)}
|
onChange={(e) => handleChange("telepon", e.target.value)}
|
||||||
placeholder="08xxxxxxxxxx"
|
placeholder="08xxxxxxxxxx"
|
||||||
className="h-11 pl-10"
|
className="h-11 pl-10"
|
||||||
/>
|
/>
|
||||||
@@ -147,31 +312,51 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="province">Provinsi</Label>
|
<Label htmlFor="desa_kelurahan_id">Desa/Kelurahan</Label>
|
||||||
<Select value={formData.province} onValueChange={(v) => handleChange("province", v)}>
|
{/* <Input
|
||||||
<SelectTrigger className="h-11">
|
id="desa_kelurahan_id"
|
||||||
<SelectValue placeholder="Pilih provinsi" />
|
value={formData.desa_kelurahan_id}
|
||||||
</SelectTrigger>
|
onChange={(e) => handleChange("desa_kelurahan_id", e.target.value)}
|
||||||
<SelectContent>
|
placeholder="Nama desa/kelurahan"
|
||||||
{provinces.length > 0 ? (
|
required
|
||||||
provinces.map((p, idx) => (
|
className="h-11"
|
||||||
<SelectItem key={p.id || idx} value={p.nama || p.name}>
|
/> */}
|
||||||
{p.nama || p.name}
|
<Select
|
||||||
</SelectItem>
|
name="desa_kelurahan_id"
|
||||||
))
|
value={formData?.desa_kelurahan_id?.toString()}
|
||||||
) : (
|
onValueChange={(e) => setFormData((prev) => ({
|
||||||
<SelectItem value="none" disabled>Data tidak tersedia</SelectItem>
|
...prev,
|
||||||
)}
|
desa_kelurahan_id: parseInt(e)
|
||||||
</SelectContent>
|
}))}
|
||||||
</Select>
|
>
|
||||||
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="regency">Kabupaten/Kota</Label>
|
<Label htmlFor="district">Kecamatan</Label>
|
||||||
<Input
|
<Input
|
||||||
id="regency"
|
id="kecamatan"
|
||||||
value={formData.regency}
|
value={locationData.districts[0]?.nama || "-"}
|
||||||
onChange={(e) => handleChange("regency", e.target.value)}
|
onChange={(e) => handleChange("kecamatan_id", e.target.value)}
|
||||||
placeholder="Nama kabupaten/kota"
|
placeholder="Nama kecamatan"
|
||||||
required
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
@@ -179,6 +364,30 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<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>
|
<Label htmlFor="district">Kecamatan</Label>
|
||||||
<Input
|
<Input
|
||||||
id="district"
|
id="district"
|
||||||
@@ -188,8 +397,8 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
|
|||||||
required
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
<div className="space-y-2">
|
{/* <div className="space-y-2">
|
||||||
<Label htmlFor="village">Desa/Kelurahan</Label>
|
<Label htmlFor="village">Desa/Kelurahan</Label>
|
||||||
<Input
|
<Input
|
||||||
id="village"
|
id="village"
|
||||||
@@ -199,7 +408,7 @@ export default function FarmerForm({ initialData, onSubmit, isLoading }) {
|
|||||||
required
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Loader2, Scale, Calendar as CalendarIcon, MapPin, TreePine } from "lucide-react";
|
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({
|
const [formData, setFormData] = useState({
|
||||||
|
user_id: 0,
|
||||||
land_id: "",
|
land_id: "",
|
||||||
plant_id: "general", // "general" jika panen seluruh lahan, atau ID tanaman spesifik
|
plant_id: "general", // "general" jika panen seluruh lahan, atau ID tanaman spesifik
|
||||||
commodity_name: "",
|
commodity_name: "",
|
||||||
@@ -18,13 +19,17 @@ export default function HarvestForm({ lands, plants, onSubmit, onCancel, isLoadi
|
|||||||
// Filter tanaman berdasarkan lahan yang dipilih
|
// Filter tanaman berdasarkan lahan yang dipilih
|
||||||
const filteredPlants = useMemo(() => {
|
const filteredPlants = useMemo(() => {
|
||||||
if (!formData.land_id) return [];
|
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]);
|
}, [plants, formData.land_id]);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.land_id || !formData.weight_kg || !formData.commodity_name) return;
|
if (!formData.land_id || !formData.weight_kg || !formData.commodity_name) return;
|
||||||
|
console.log("Submitting harvest data => ", formData);
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...formData,
|
...formData,
|
||||||
weight_kg: parseFloat(formData.weight_kg),
|
weight_kg: parseFloat(formData.weight_kg),
|
||||||
@@ -47,7 +52,7 @@ export default function HarvestForm({ lands, plants, onSubmit, onCancel, isLoadi
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{lands.map(land => (
|
{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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -76,8 +81,8 @@ export default function HarvestForm({ lands, plants, onSubmit, onCancel, isLoadi
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="general">Hasil Lahan Umum</SelectItem>
|
<SelectItem value="general">Hasil Lahan Umum</SelectItem>
|
||||||
{filteredPlants.map(plant => (
|
{filteredPlants.map(plant => (
|
||||||
<SelectItem key={plant.id} value={plant.id}>
|
<SelectItem key={plant.id.toString()} value={plant.id.toString()}>
|
||||||
{plant.commodity_name} (ID: {plant.id.slice(-4)})
|
{plant.nama || 'N/A'} (ID: {plant.id.toString().slice(-4)})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -55,11 +55,21 @@ export default function GPSLandMapper({ onSave, readOnly = false }) {
|
|||||||
toast.error("Minimal harus ada 3 titik");
|
toast.error("Minimal harus ada 3 titik");
|
||||||
return;
|
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({
|
onSave({
|
||||||
polygon_coordinates: points.map(p => [p[1], p[0]]), // [lng, lat]
|
polygon_coordinates: points.map(p => [p[1], p[0]]), // Format [lng, lat] untuk backend
|
||||||
center_lat: points.reduce((s, p) => s + p[0], 0) / points.length,
|
center_lat: centerLat,
|
||||||
center_lng: points.reduce((s, p) => s + p[1], 0) / points.length,
|
center_lng: centerLng,
|
||||||
area_hectares: area
|
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">
|
<div className="flex gap-2">
|
||||||
{!isTracking ? (
|
{!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
|
<Zap className="mr-2 h-4 w-4" /> Mulai Ukur Lahan
|
||||||
</Button>
|
</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})
|
<Navigation className="mr-2 h-4 w-4" /> Tandai Sudut ({points.length})
|
||||||
</Button>
|
</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" />
|
<ZapOff className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -178,6 +188,7 @@ export default function GPSLandMapper({ onSave, readOnly = false }) {
|
|||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={() => onSave({ points, area })}
|
onClick={() => onSave({ points, area })}
|
||||||
className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg px-6 h-11 rounded-xl font-bold"
|
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 && (
|
{points.length > 0 && (
|
||||||
<div className="absolute bottom-6 left-4 right-4 flex gap-2 z-[1000]">
|
<div className="absolute bottom-6 left-4 right-4 flex gap-2 z-[1000]">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex-1 bg-white/90 backdrop-blur shadow-md"
|
className="flex-1 bg-white/90 backdrop-blur shadow-md"
|
||||||
onClick={() => setPoints(points.slice(0, -1))}
|
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
|
<RotateCcw className="w-4 h-4 mr-2" /> Batal
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex-1 shadow-md"
|
className="flex-1 shadow-md"
|
||||||
onClick={() => setPoints([])}
|
onClick={() => setPoints([])}
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ const statusColors = {
|
|||||||
government: "Tanah Negara"
|
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();
|
const [village, setVillage] = useState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchVillages = async () => {
|
const fetchVillages = async () => {
|
||||||
const villages = await OfflineService.getEntities('villages');
|
const villages = await OfflineService.getEntities('villages');
|
||||||
@@ -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>
|
<span className="text-xs text-slate-500">Status: {statusColors[land.land_status]}</span>
|
||||||
</div>
|
</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>
|
</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 }) {
|
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 queryClient = useQueryClient();
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
|
tanaman_id:plant?.id,
|
||||||
|
lahan_id:plant?.lahan?.id,
|
||||||
health_status: "healthy",
|
health_status: "healthy",
|
||||||
productivity_status: "productive",
|
productivity_status: "productive",
|
||||||
issue_type: "none",
|
issue_type: "none",
|
||||||
@@ -49,30 +52,30 @@ export default function PlantInspectionForm({ plant,farmer_id, onClose, onSucces
|
|||||||
|
|
||||||
const inspectionMutation = useMutation({
|
const inspectionMutation = useMutation({
|
||||||
mutationFn: async (data) => {
|
mutationFn: async (data) => {
|
||||||
console.log("create inspection", data)
|
|
||||||
try{
|
try{
|
||||||
const existingInspeksi = await entity('map', 'inspeksi').find({
|
const existingInspeksi = await entity('map', 'inspeksi/tanaman').find({
|
||||||
petani_id: farmer_id,
|
petani_id: farmer_id,
|
||||||
lahan_id: plant.lahan_id,
|
lahan_id: plant.lahan_id,
|
||||||
tanaman_id: plant.id
|
tanaman_id: plant.id
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(existingInspeksi.data.data, 'inspeksi');
|
|
||||||
const payload = {
|
const payload = {
|
||||||
tanaman_id: plant.id,
|
tanaman_id: plant.id,
|
||||||
lahan_id: plant.lahan_id,
|
lahan_id: plant?.lahan?.id,
|
||||||
petani_id: farmer_id,
|
petani_id: farmer_id,
|
||||||
inspection_date: new Date().toISOString(),
|
inspection_date: new Date().toISOString(),
|
||||||
...data
|
...data
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingInspeksi.data.data && existingInspeksi.data.data.length > 0) {
|
if (existingInspeksi.data.data && existingInspeksi.data.data.length > 0) {
|
||||||
const idToUpdate = existingInspeksi.data.data[0].id;
|
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");
|
console.log("Data updated successfully");
|
||||||
} else {
|
} else {
|
||||||
await entity('map', 'inspeksi').create(payload);
|
await entity('map', 'inspeksi/tanaman').create(payload);
|
||||||
console.log("Data created successfully");
|
console.log("Data created successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
// await entity('map', 'inspeksi').create({
|
// await entity('map', 'inspeksi').create({
|
||||||
// 'tanaman_id' : plant.id,
|
// 'tanaman_id' : plant.id,
|
||||||
// "lahan_id" : plant.lahan_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 {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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 {
|
html {
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@ export const PAGES = {
|
|||||||
"Validators": {component: Validators, roles: ['super_admin']},
|
"Validators": {component: Validators, roles: ['super_admin']},
|
||||||
"ValidatorPortal": {component: ValidatorPortal, roles: ['super_admin']},
|
"ValidatorPortal": {component: ValidatorPortal, roles: ['super_admin']},
|
||||||
"Offtakers": {component: Offtakers, 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']},
|
"ProductivityMonitoring": {component: ProductivityMonitoring, roles:['super_admin', 'petani']},
|
||||||
"Login": {component: Login, roles:[]},
|
"Login": {component: Login, roles:[]},
|
||||||
"Register": {component: Register, 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" },
|
less_productive: { label: "Kurang Produktif", color: "bg-amber-100 text-amber-700" },
|
||||||
not_productive: { label: "Tidak Produktif", color: "bg-red-100 text-red-700" }
|
not_productive: { label: "Tidak Produktif", color: "bg-red-100 text-red-700" }
|
||||||
};
|
};
|
||||||
|
|
||||||
const tok = localStorage.getItem('access_token');
|
const tok = localStorage.getItem('access_token');
|
||||||
const us = localStorage.getItem('user_data');
|
const us = localStorage.getItem('user_data');
|
||||||
|
|
||||||
console.log('user data dan token ', tok,us)
|
|
||||||
const { data: rawfarmers = [] } = useQuery({
|
const { data: rawfarmers = [] } = useQuery({
|
||||||
queryKey: ["farmers"],
|
queryKey: ["farmers"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await entity('map','tanaman').list();
|
const res = await entity('auth','profile').list();
|
||||||
return res.data;
|
const datas = res?.data?.data || res?.data || [];
|
||||||
|
return datas?.data.filter(data => data?.user?.role.toString() === 'petani');
|
||||||
},
|
},
|
||||||
retry: false
|
retry: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: rawlands = [] } = useQuery({
|
const { data: rawlands = [] } = useQuery({
|
||||||
queryKey: ["lands"],
|
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
|
retry: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: rawplants = [] } = useQuery({
|
const { data: rawplants = [] } = useQuery({
|
||||||
queryKey: ["plants"],
|
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
|
retry: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const farmers = (Array.isArray(rawfarmers) && rawfarmers.length > 0) ? rawfarmers : (mockFarmers || []);
|
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 plants = (Array.isArray(rawplants) && rawplants.length > 0) ? rawplants : (mockPlants || []);
|
||||||
|
|
||||||
const safeFarmers = Array.isArray(farmers) ? farmers : [];
|
const safeFarmers = Array.isArray(farmers) ? farmers : [];
|
||||||
const safeLands = Array.isArray(lands) ? lands : [];
|
const safeLands = Array.isArray(rawlands) ? rawlands : [];
|
||||||
const safePlants = Array.isArray(plants) ? plants : [];
|
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 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 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 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;
|
const verifiedFarmers = safeFarmers.filter(f => f.verification_status === "verified").length;
|
||||||
|
|
||||||
// Region data
|
// Region data
|
||||||
@@ -73,20 +109,22 @@ export default function Dashboard() {
|
|||||||
// }, []).sort((a, b) => b.value - a.value).slice(0, 5);
|
// }, []).sort((a, b) => b.value - a.value).slice(0, 5);
|
||||||
|
|
||||||
// new
|
// new
|
||||||
const regionData = safeLands.reduce((acc, land) => {
|
const regionMap = safeLands.reduce((acc, land) => {
|
||||||
const region = land.regency || "Tidak Diketahui";
|
const region = land?.desa_kelurahan?.kecamatan?.nama || "Tidak Diketahui";
|
||||||
const existing = acc.find(r => r.name === region);
|
const area = parseFloat(land?.luas_lahan) || 1;
|
||||||
if (existing) {
|
|
||||||
existing.value += (Number(land.area_hectares) || 0);
|
acc[region] = (acc[region] || 0) + area;
|
||||||
} else {
|
|
||||||
acc.push({ name: region, value: (Number(land.area_hectares) || 0) });
|
|
||||||
}
|
|
||||||
return acc;
|
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
|
// Commodity data
|
||||||
const commodityData = plants.reduce((acc, plant) => {
|
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);
|
const existing = acc.find(c => c.name === commodity);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.value += 1;
|
existing.value += 1;
|
||||||
@@ -99,10 +137,10 @@ export default function Dashboard() {
|
|||||||
// Issues
|
// Issues
|
||||||
const pendingValidation = lands.filter(l => l.validation_status === "pending" || l.validation_status === "need_review").length;
|
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 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 (
|
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">
|
<div className="max-w-7xl mx-auto p-6 space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<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 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">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-slate-900 truncate">{farmer.full_name}</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.district}</p>
|
<p className="text-sm text-slate-500">{farmer.village || farmer?.alamat || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
<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-emerald-100 text-emerald-700"
|
||||||
: "bg-amber-100 text-amber-700"
|
: "bg-amber-100 text-amber-700"
|
||||||
}`}>
|
}`}>
|
||||||
{farmer.verification_status === "verified" ? "Terverifikasi" : "Pending"}
|
{farmer?.user?.email_verified_at !== null ? "Terverifikasi" : "Pending"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+31
-29
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import FarmerForm from "@/components/farmers/FarmerForm";
|
import FarmerForm from "@/components/farmers/FarmerForm";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Edit, MapPin, Phone, Users, CreditCard,
|
ArrowLeft, Edit, MapPin, Phone, Users, CreditCard,
|
||||||
Map, Plus, CheckCircle, XCircle, Clock
|
Map as MapIcon, Plus, CheckCircle, XCircle, Clock
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import LinkUserAccount from "@/components/farmers/LinkUserAccount";
|
import LinkUserAccount from "@/components/farmers/LinkUserAccount";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -33,8 +33,8 @@ export default function FarmerDetail() {
|
|||||||
const { data: farmers = [] } = useQuery({
|
const { data: farmers = [] } = useQuery({
|
||||||
queryKey: ["farmers"],
|
queryKey: ["farmers"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await entity('farmer').list();
|
const data = await entity('map','petani').list();
|
||||||
return data ?? [];
|
return data?.data ? data?.data : data?.data?.data ? data.data.data : [];
|
||||||
},
|
},
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
@@ -43,14 +43,16 @@ export default function FarmerDetail() {
|
|||||||
queryKey: ['farmer', id],
|
queryKey: ['farmer', id],
|
||||||
queryFn: async (data) => {
|
queryFn: async (data) => {
|
||||||
if(!id) return null;
|
if(!id) return null;
|
||||||
console.log(id, paramsId);
|
|
||||||
try{
|
try{
|
||||||
const response = await entity('map','petani').get(id);
|
const response = await entity('auth','profile').findOne(id);
|
||||||
console.log(response);
|
|
||||||
if (typeof response === 'string' && response.includes('<!doctype html>')) {
|
if (typeof response === 'string' && response.includes('<!doctype html>')) {
|
||||||
console.warn("API returned HTML instead of JSON. Falling back to local.");
|
console.warn("API returned HTML instead of JSON. Falling back to local.");
|
||||||
} else if (response && !response.error) {
|
} 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) {
|
} catch (e) {
|
||||||
console.warn("Server unreachable, searching locally...");
|
console.warn("Server unreachable, searching locally...");
|
||||||
@@ -65,7 +67,7 @@ export default function FarmerDetail() {
|
|||||||
enabled : !!id,
|
enabled : !!id,
|
||||||
initialData: null
|
initialData: null
|
||||||
});
|
});
|
||||||
console.log('Farmer Detail', farmer)
|
|
||||||
// const { data: landsRaw = [] } = useQuery({
|
// const { data: landsRaw = [] } = useQuery({
|
||||||
// queryKey: ["lands", farmerId],
|
// queryKey: ["lands", farmerId],
|
||||||
// queryFn: () => base44.entities.Land.list(),
|
// queryFn: () => base44.entities.Land.list(),
|
||||||
@@ -78,24 +80,26 @@ export default function FarmerDetail() {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
let serverData = [];
|
let serverData = [];
|
||||||
try {
|
try {
|
||||||
const resp = await entity('land').list("-created_date");
|
const resp = await entity('map', 'lahan').list();
|
||||||
// Cek apakah response string HTML (error fallback server)
|
|
||||||
if (typeof resp === 'string' && resp.includes('<!doctype html>')) {
|
if (typeof resp === 'string' && resp.includes('<!doctype html>')) {
|
||||||
console.warn("Server returned HTML for Land list");
|
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) {
|
} catch (e) {
|
||||||
console.warn("Server lands unreachable");
|
console.warn("Server lands unreachable", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ambil data lokal
|
// Ambil data lokal
|
||||||
const localData = await OfflineService.getEntities("lands");
|
const localData = await OfflineService.getEntities("lands");
|
||||||
|
|
||||||
const combined = new Map();
|
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); });
|
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());
|
const result = Array.from(combined.values());
|
||||||
console.log("🔍 Dexie & Server Merge:", result.length, "items");
|
console.log("🔍 Dexie & Server Merge:", result.length, "items");
|
||||||
return result;
|
return result;
|
||||||
@@ -103,7 +107,6 @@ export default function FarmerDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lands = useMemo(() => {
|
const lands = useMemo(() => {
|
||||||
// Pastikan menggunakan rawLands (sesuai destructuring di atas)
|
|
||||||
const safeLands = Array.isArray(rawLands) ? rawLands : [];
|
const safeLands = Array.isArray(rawLands) ? rawLands : [];
|
||||||
|
|
||||||
// Debugging: Cek apakah ID yang dicari ada di dalam daftar
|
// Debugging: Cek apakah ID yang dicari ada di dalam daftar
|
||||||
@@ -111,7 +114,7 @@ export default function FarmerDetail() {
|
|||||||
|
|
||||||
return safeLands.filter(land => {
|
return safeLands.filter(land => {
|
||||||
// Gunakan == untuk jaga-jaga jika ID satu string dan satu number
|
// Gunakan == untuk jaga-jaga jika ID satu string dan satu number
|
||||||
return land.farmer_id == id;
|
return land.profile_id == id;
|
||||||
});
|
});
|
||||||
}, [rawLands, id]);
|
}, [rawLands, id]);
|
||||||
|
|
||||||
@@ -253,8 +256,8 @@ export default function FarmerDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">{farmer.full_name}</h1>
|
<h1 className="text-2xl font-bold text-slate-900">{farmer.nama}</h1>
|
||||||
<p className="text-slate-500">NIK: {farmer.nik}</p>
|
<p className="text-slate-500">NIK: {farmer.ktp}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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="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">
|
<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 ? (
|
{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">
|
<span className="text-3xl font-bold text-emerald-700">
|
||||||
{farmer.full_name?.charAt(0)?.toUpperCase()}
|
{farmer.nama?.charAt(0)?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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`}>
|
<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}`} />
|
<StatusIcon className={`w-4 h-4 ${statusConfig.text}`} />
|
||||||
<span className={`text-sm font-medium ${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" />
|
<CreditCard className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500">NIK</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{farmer.phone && (
|
{farmer.telepon && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Phone className="w-5 h-5 text-slate-400 mt-0.5" />
|
<Phone className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500">Telepon</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -353,8 +356,7 @@ export default function FarmerDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500">Alamat</p>
|
<p className="text-xs text-slate-500">Alamat</p>
|
||||||
<p className="font-medium text-slate-900">
|
<p className="font-medium text-slate-900">
|
||||||
{farmer.village}, {farmer.district}<br/>
|
{farmer.alamat}
|
||||||
{farmer.regency}, {farmer.province}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,7 +415,7 @@ export default function FarmerDetail() {
|
|||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<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
|
Lahan Terdaftar
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Link to={createPageUrl("LandRegister") + `?farmer_id=${id}`}>
|
<Link to={createPageUrl("LandRegister") + `?farmer_id=${id}`}>
|
||||||
@@ -436,7 +438,7 @@ export default function FarmerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-slate-500">
|
<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>
|
<p>Belum ada lahan terdaftar</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function FarmerPortal() {
|
|||||||
// Not logged in
|
// Not logged in
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|||||||
+194
-69
@@ -22,6 +22,7 @@ import {
|
|||||||
Edit, Loader2, MapPin, Phone, Users as UsersIcon, ClipboardCheck, AlertTriangle, Bug, Send, Package,
|
Edit, Loader2, MapPin, Phone, Users as UsersIcon, ClipboardCheck, AlertTriangle, Bug, Send, Package,
|
||||||
Save, CreditCard, FileText
|
Save, CreditCard, FileText
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import LandInspectionForm from "@/components/lands/LandInspectionForm";
|
||||||
import PlantInspectionForm from "@/components/plants/PlantInspectionForm";
|
import PlantInspectionForm from "@/components/plants/PlantInspectionForm";
|
||||||
import DistributionForm from "@/components/distribution/DistributionForm";
|
import DistributionForm from "@/components/distribution/DistributionForm";
|
||||||
import HarvestForm from "@/components/harvest/HarvestForm";
|
import HarvestForm from "@/components/harvest/HarvestForm";
|
||||||
@@ -29,6 +30,7 @@ import { toast } from "sonner";
|
|||||||
import { motion, sync } from "framer-motion";
|
import { motion, sync } from "framer-motion";
|
||||||
import { OfflineService } from "@/components/common/offlineStorage";
|
import { OfflineService } from "@/components/common/offlineStorage";
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
||||||
export default function FarmerPortal() {
|
export default function FarmerPortal() {
|
||||||
@@ -41,6 +43,7 @@ export default function FarmerPortal() {
|
|||||||
const [showTagPlant, setShowTagPlant] = useState(false);
|
const [showTagPlant, setShowTagPlant] = useState(false);
|
||||||
const [selectedLand, setSelectedLand] = useState(null);
|
const [selectedLand, setSelectedLand] = useState(null);
|
||||||
const [inspectingPlant, setInspectingPlant] = useState(null);
|
const [inspectingPlant, setInspectingPlant] = useState(null);
|
||||||
|
const [inspectingLand, setInspectingLand] = useState(null);
|
||||||
const [showDistribution, setShowDistribution] = useState(false);
|
const [showDistribution, setShowDistribution] = useState(false);
|
||||||
const [showHarvestForm, setShowHarvestForm] = useState(false);
|
const [showHarvestForm, setShowHarvestForm] = useState(false);
|
||||||
// Offline entity hooks
|
// Offline entity hooks
|
||||||
@@ -58,36 +61,73 @@ export default function FarmerPortal() {
|
|||||||
villages: []
|
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(() => {
|
useEffect(() => {
|
||||||
const prepareDataLocation = async() => {
|
const prepareDataLocation = async () => {
|
||||||
if(!navigator.onLine){
|
// 1. Cek status koneksi
|
||||||
try{
|
const isOnline = navigator.onLine;
|
||||||
const [villages, districts, regencies, provinces] = await Promise.all([
|
|
||||||
OfflineService.getEntities('villages'),
|
try {
|
||||||
OfflineService.getEntities('districts'),
|
let data;
|
||||||
OfflineService.getEntities('regencies'),
|
if (isOnline) {
|
||||||
OfflineService.getEntities('provinces'),
|
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({
|
data = {
|
||||||
villages: villages, districts:districts, regencies:regencies,provinces:provinces});
|
villages: v || [],
|
||||||
}catch(err){
|
districts: d || [],
|
||||||
console.error(err)
|
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});
|
setLocationData(data);
|
||||||
console.log("Offline")
|
} catch (err) {
|
||||||
}catch(err){
|
console.error("Gagal memuat data lokasi:", err);
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,6 +195,7 @@ export default function FarmerPortal() {
|
|||||||
try{
|
try{
|
||||||
const resp = await entity('distribusi','panen').list();
|
const resp = await entity('distribusi','panen').list();
|
||||||
let data = resp.data || [];
|
let data = resp.data || [];
|
||||||
|
|
||||||
data = data.filter((dt) => dt.farmer_id === farmer.id)
|
data = data.filter((dt) => dt.farmer_id === farmer.id)
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -177,9 +218,11 @@ export default function FarmerPortal() {
|
|||||||
queryFn: async() => {
|
queryFn: async() => {
|
||||||
try{
|
try{
|
||||||
const response = await entity('map','panen').list();
|
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){
|
if(data && data.length > 0){
|
||||||
return data;
|
const filtered = data.filter((dt) => dt.farmer_id === farmer.profile_id);
|
||||||
|
return filtered;
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}catch(err){
|
}catch(err){
|
||||||
@@ -198,21 +241,68 @@ export default function FarmerPortal() {
|
|||||||
enabled: !!farmer?.id
|
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({
|
const { data: lands = [] } = useQuery({
|
||||||
queryKey: ["lands", farmer?.id],
|
queryKey: ["lands", farmer?.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
let lahans = [];
|
let lahans = [];
|
||||||
try{
|
try{
|
||||||
const listLahan = await entity('map', 'lahan').list();
|
|
||||||
lahans = Array.isArray(listLahan.data)
|
const fetchInspections = async () => {
|
||||||
? listLahan.data.filter(l => l.profile_id === profile.id)
|
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){
|
}catch(err){
|
||||||
console.warn("Online data is un reachable")
|
console.warn("Online data is un reachable", err)
|
||||||
}
|
}
|
||||||
try{
|
try{
|
||||||
console.log(profile?.id || 0, "PRofile id")
|
|
||||||
const offlineData = await OfflineService.getEntities('lands');
|
const offlineData = await OfflineService.getEntities('lands');
|
||||||
const offlineLahan = safeArray(offlineData).filter(lh => lh.profile_id === profile.id);
|
const offlineLahan = safeArray(offlineData).filter(lh => lh.profile_id === profile.id);
|
||||||
const combined = new Map();
|
const combined = new Map();
|
||||||
@@ -225,19 +315,20 @@ export default function FarmerPortal() {
|
|||||||
);
|
);
|
||||||
}catch(err){
|
}catch(err){
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
return lahans;
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
enabled: !!farmer?.id
|
enabled: !!farmer?.id && !!profile?.id
|
||||||
});
|
});
|
||||||
|
console.log("Data Lahan => ", lands)
|
||||||
const { data: plants = [] } = useQuery({
|
const { data: plants = [] } = useQuery({
|
||||||
queryKey: ["farmer-plants", farmer?.id],
|
queryKey: ["farmer-plants", farmer?.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const [response, inspctResponse] = await Promise.all([
|
const [response, inspctResponse] = await Promise.all([
|
||||||
entity('map', 'tanaman').list(),
|
entity('map', 'tanaman').list(),
|
||||||
entity('map', 'inspeksi').list()
|
entity('map', 'inspeksi/tanaman').list()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const $rawPlants = response.data.data ?? [];
|
const $rawPlants = response.data.data ?? [];
|
||||||
@@ -256,15 +347,14 @@ export default function FarmerPortal() {
|
|||||||
|
|
||||||
const mergedPlants = $rawPlants.map((plant) => {
|
const mergedPlants = $rawPlants.map((plant) => {
|
||||||
const inspections = inspectionMap.get(plant.id.toString()) || [];
|
const inspections = inspectionMap.get(plant.id.toString()) || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...plant,
|
...plant,
|
||||||
inspections: inspections,
|
inspections: inspections,
|
||||||
latest_inspection: inspections.length > 0 ? inspections[inspections.length - 1] : null
|
latest_inspection: inspections.length > 0 ? inspections[inspections.length - 1] : null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// const filteredPlants = mergedPlants.filter(p => p.lahan.profile_id === farmer.profile_id);
|
||||||
return mergedPlants;
|
return mergedPlants.filter(p => Number(p.lahan.profile_id) === Number(farmer.profile_id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Gagal mengambil data gabungan:", error);
|
console.error("Gagal mengambil data gabungan:", error);
|
||||||
return [];
|
return [];
|
||||||
@@ -282,6 +372,7 @@ export default function FarmerPortal() {
|
|||||||
return await entity('map', 'lahan').create({
|
return await entity('map', 'lahan').create({
|
||||||
...data,
|
...data,
|
||||||
farmer_id: farmer.id,
|
farmer_id: farmer.id,
|
||||||
|
profile_id: profile?.id,
|
||||||
validation_status: "pending",
|
validation_status: "pending",
|
||||||
sync_status: "pending"
|
sync_status: "pending"
|
||||||
})
|
})
|
||||||
@@ -311,7 +402,7 @@ export default function FarmerPortal() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate agar UI mengambil data dari local DB (IndexedDB)
|
// Invalidate agar UI mengambil data dari local DB (IndexedDB)
|
||||||
queryClient.invalidateQueries({ queryKey: ["farmer-lands"] });
|
queryClient.invalidateQueries({ queryKey: ["lands", farmer?.id] });
|
||||||
// resetForm();
|
// resetForm();
|
||||||
toast.info("Data disimpan secara lokal (Offline)");
|
toast.info("Data disimpan secara lokal (Offline)");
|
||||||
} catch (localErr) {
|
} catch (localErr) {
|
||||||
@@ -338,12 +429,15 @@ export default function FarmerPortal() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createHarvestMutation = useMutation({
|
const createHarvestMutation = useMutation({
|
||||||
mutationFn: (data) => harvestEntity.create({
|
mutationFn: (data) => {
|
||||||
...data,
|
const payloads = {
|
||||||
farmer_id: farmer.id,
|
...data,
|
||||||
sync_status: "pending",
|
farmer_id: farmer.id,
|
||||||
created_at: new Date().toISOString()
|
sync_status: "pending",
|
||||||
}),
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
return entity('map', 'panen').create(payloads);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["farmer-harvests"] });
|
queryClient.invalidateQueries({ queryKey: ["farmer-harvests"] });
|
||||||
setShowHarvestForm(false);
|
setShowHarvestForm(false);
|
||||||
@@ -354,7 +448,7 @@ export default function FarmerPortal() {
|
|||||||
|
|
||||||
const updateProfileMutation = useMutation({
|
const updateProfileMutation = useMutation({
|
||||||
mutationFn: async (data) => {
|
mutationFn: async (data) => {
|
||||||
console.log(profile)
|
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
try{
|
try{
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
@@ -436,7 +530,7 @@ export default function FarmerPortal() {
|
|||||||
if (!currentUser || (typeof currentUser === 'string' && currentUser.includes('<!doctype html>'))) {
|
if (!currentUser || (typeof currentUser === 'string' && currentUser.includes('<!doctype html>'))) {
|
||||||
throw { type: 'OFFLINE_DATA' };
|
throw { type: 'OFFLINE_DATA' };
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('user_data', JSON.stringify(currentUser));
|
localStorage.setItem('user_data', JSON.stringify(currentUser));
|
||||||
}catch(err){
|
}catch(err){
|
||||||
console.error("Error : ", err)
|
console.error("Error : ", err)
|
||||||
@@ -587,7 +681,7 @@ export default function FarmerPortal() {
|
|||||||
setKtpPreview(URL.createObjectURL(fileKtp));
|
setKtpPreview(URL.createObjectURL(fileKtp));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-emerald-50 to-slate-100 flex items-center justify-center">
|
<div className="min-h-screen bg-gradient-to-br from-emerald-50 to-slate-100 flex items-center justify-center">
|
||||||
@@ -622,11 +716,9 @@ export default function FarmerPortal() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged in but no farmer profile linked
|
|
||||||
|
|
||||||
if (!farmer) {
|
if (!farmer) {
|
||||||
return (
|
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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -681,7 +773,7 @@ export default function FarmerPortal() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
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">
|
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
|
||||||
{/* Sync Status Bar */}
|
{/* Sync Status Bar */}
|
||||||
<SyncStatusBar />
|
<SyncStatusBar />
|
||||||
@@ -753,24 +845,25 @@ export default function FarmerPortal() {
|
|||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="lands" className="space-y-4">
|
<Tabs defaultValue="lands" className="space-y-4">
|
||||||
<TabsList className="w-full bg-white shadow-sm p-1 h-auto flex-wrap">
|
<TabsList className="w-full bg-white shadow-sm p-1 h-auto flex-wrap gap-1">
|
||||||
<TabsTrigger value="lands" className="flex-1 py-3 data-[state=active]:bg-emerald-50 data-[state=active]:text-emerald-700">
|
<TabsTrigger value="lands" className={cn("flex-1 py-3 data-[state=active]:bg-emerald-50")}>
|
||||||
<MapPin className="w-4 h-4 mr-2" />
|
<MapPin className="w-4 h-4 mr-2" />
|
||||||
Lahan
|
Lahan
|
||||||
</TabsTrigger>
|
</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" />
|
<TreePine className="w-4 h-4 mr-2" />
|
||||||
Tanaman
|
Tanaman
|
||||||
</TabsTrigger>
|
</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" />
|
<Package className="w-4 h-4 mr-2" />
|
||||||
Panen
|
Panen
|
||||||
</TabsTrigger>
|
</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" />
|
<Truck className="w-4 h-4 mr-2" />
|
||||||
Distribusi
|
Distribusi
|
||||||
</TabsTrigger>
|
</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" />
|
<User className="w-4 h-4 mr-2" />
|
||||||
Profil
|
Profil
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -780,7 +873,7 @@ export default function FarmerPortal() {
|
|||||||
<TabsContent value="lands" className="space-y-4">
|
<TabsContent value="lands" className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-lg font-semibold text-slate-900">Daftar Lahan</h2>
|
<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" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Tambah Lahan
|
Tambah Lahan
|
||||||
</Button>
|
</Button>
|
||||||
@@ -792,7 +885,8 @@ export default function FarmerPortal() {
|
|||||||
<div key={land.id} className="relative">
|
<div key={land.id} className="relative">
|
||||||
<LandCard
|
<LandCard
|
||||||
land={land}
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -804,7 +898,18 @@ export default function FarmerPortal() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TreePine className="w-4 h-4 mr-1" />
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -829,6 +934,7 @@ export default function FarmerPortal() {
|
|||||||
{plants.map(plant => {
|
{plants.map(plant => {
|
||||||
const land = lands.find(l => l.id === plant.lahan_id);
|
const land = lands.find(l => l.id === plant.lahan_id);
|
||||||
const hasIssue = plant.inspections?.[0]?.issue_type && plant.inspections?.[0]?.issue_type !== "none";
|
const hasIssue = plant.inspections?.[0]?.issue_type && plant.inspections?.[0]?.issue_type !== "none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={plant.id} className="border-0 shadow-sm p-4">
|
<Card key={plant.id} className="border-0 shadow-sm p-4">
|
||||||
<div className="flex items-center justify-between">
|
<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 === "productive" ? "bg-emerald-500" :
|
||||||
plant.inspections?.[0]?.productivity_status === "less_productive" ? "bg-amber-500" :
|
plant.inspections?.[0]?.productivity_status === "less_productive" ? "bg-amber-500" :
|
||||||
plant.inspections?.[0]?.productivity_status === "not_productive" ? "bg-red-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"
|
plant.status === "dead" ? "bg-slate-400" : "bg-emerald-500"
|
||||||
}`} />
|
}`} />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-slate-900">{plant.nama}</p>
|
<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 && (
|
{hasIssue && (
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<div className="flex items-center gap-1 mt-1">
|
||||||
<Bug className="w-3 h-3 text-amber-500" />
|
<Bug className="w-3 h-3 text-amber-500" />
|
||||||
@@ -902,7 +1008,9 @@ export default function FarmerPortal() {
|
|||||||
|
|
||||||
{harvests.length > 0 ? (
|
{harvests.length > 0 ? (
|
||||||
<div className="grid gap-3">
|
<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">
|
<Card key={harvest.id} className="border-0 shadow-sm p-4">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
@@ -915,10 +1023,10 @@ export default function FarmerPortal() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
||||||
<MapPin className="w-3 h-3" />
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
)})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -1322,7 +1430,7 @@ export default function FarmerPortal() {
|
|||||||
{inspectingPlant && (
|
{inspectingPlant && (
|
||||||
<PlantInspectionForm
|
<PlantInspectionForm
|
||||||
plant={inspectingPlant}
|
plant={inspectingPlant}
|
||||||
farmer_id={farmer?.id}
|
farmer_id={farmer?.profile_id}
|
||||||
onClose={() => setInspectingPlant(null)}
|
onClose={() => setInspectingPlant(null)}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["farmer-plants"] });
|
queryClient.invalidateQueries({ queryKey: ["farmer-plants"] });
|
||||||
@@ -1353,6 +1461,7 @@ export default function FarmerPortal() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<HarvestForm
|
<HarvestForm
|
||||||
|
farmer={farmer}
|
||||||
lands={lands}
|
lands={lands}
|
||||||
plants={plants}
|
plants={plants}
|
||||||
onSubmit={(data) => createHarvestMutation.mutate(data)}
|
onSubmit={(data) => createHarvestMutation.mutate(data)}
|
||||||
@@ -1362,6 +1471,22 @@ export default function FarmerPortal() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
import base44 from "@/api/Client";
|
import base44 from "@/api/Client";
|
||||||
import { entity } from "@/api/entities";
|
import { entity } from "@/api/entities";
|
||||||
import { createPageUrl } from "@/utils";
|
import { createPageUrl } from "@/utils";
|
||||||
@@ -9,7 +10,6 @@ import { ArrowLeft } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
// Import Offline Service yang baru kita buat
|
|
||||||
import { OfflineService } from "@/components/common/offlineStorage";
|
import { OfflineService } from "@/components/common/offlineStorage";
|
||||||
|
|
||||||
export default function FarmerRegister() {
|
export default function FarmerRegister() {
|
||||||
@@ -19,14 +19,39 @@ export default function FarmerRegister() {
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data) => {
|
mutationFn: async (data) => {
|
||||||
console.log("1. Memulai proses simpan...", data);
|
console.log("1. Memulai proses simpan...", data);
|
||||||
|
if(!data?.password && !data?.password_confirmation){
|
||||||
|
console.log("password", data.password, data.password_confirmation)
|
||||||
|
return toast.error("Passwod Harus dibuat");
|
||||||
|
}
|
||||||
|
if (data?.password !== data?.password_confirmation) {
|
||||||
|
console.log("password tidak cocol", data.password, data.password_confirmation);
|
||||||
|
return toast.error("Konfirmasi password tidak cocok");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// return await base44.entities.Farmer.create(data);
|
// check user
|
||||||
const response = await entity('map', 'lahan').create(data);
|
const respupdate = await entity('auth', `profile`).find({email:data?.email});
|
||||||
if (typeof response === 'string' && response.includes('<!doctype html>')) {
|
const dataToUpdate = Array.isArray(respupdate?.data) ? respupdate?.data[0] : Array.isArray(respupdate?.data?.data) ? respupdate.data?.data[0] : [];
|
||||||
throw new Error("Menerima HTML, bukan JSON. Endpoint mungkin salah.");
|
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;
|
||||||
}
|
}
|
||||||
console.log("2. Berhasil simpan ke server", response);
|
|
||||||
return response;
|
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) {
|
} catch (error) {
|
||||||
console.log("2. Gagal ke server, beralih ke offline mode", error);
|
console.log("2. Gagal ke server, beralih ke offline mode", error);
|
||||||
throw { type: 'OFFLINE_SAVE', data };
|
throw { type: 'OFFLINE_SAVE', data };
|
||||||
@@ -37,30 +62,30 @@ export default function FarmerRegister() {
|
|||||||
toast.success("Petani berhasil didaftarkan ke server!");
|
toast.success("Petani berhasil didaftarkan ke server!");
|
||||||
navigate(createPageUrl("Farmers"));
|
navigate(createPageUrl("Farmers"));
|
||||||
},
|
},
|
||||||
onError: async (error, variables) => {
|
// onError: async (error, variables) => {
|
||||||
if (error.type === 'OFFLINE_SAVE') {
|
// if (error.type === 'OFFLINE_SAVE') {
|
||||||
try {
|
// try {
|
||||||
// 1. Simpan ke SQLite
|
// // 1. Simpan ke SQLite
|
||||||
const entity = 'farmer';
|
// const entity = 'farmer';
|
||||||
const localId = await OfflineService.saveEntityLocally(entity,variables);
|
// const localId = await OfflineService.saveEntityLocally(entity,variables);
|
||||||
const newFarmer = { ...variables, id: localId, sync_status: 'pending' }
|
// const newFarmer = { ...variables, id: localId, sync_status: 'pending' }
|
||||||
// 2. Update Cache TanStack Query secara manual (Optimistic Update)
|
// // 2. Update Cache TanStack Query secara manual (Optimistic Update)
|
||||||
queryClient.setQueryData(["farmers"], (oldData) => {
|
// queryClient.setQueryData(["farmers"], (oldData) => {
|
||||||
return oldData ? [newFarmer, ...oldData] : [newFarmer];
|
// return oldData ? [newFarmer, ...oldData] : [newFarmer];
|
||||||
});
|
// });
|
||||||
|
|
||||||
toast.info("Tersimpan secara lokal");
|
// toast.info("Tersimpan secara lokal");
|
||||||
// 3. Beri sedikit jeda agar Toast/Cache sempat terproses sebelum pindah halaman
|
// // 3. Beri sedikit jeda agar Toast/Cache sempat terproses sebelum pindah halaman
|
||||||
setTimeout(() => {
|
// setTimeout(() => {
|
||||||
navigate(createPageUrl("Farmers"));
|
// navigate(createPageUrl("Farmers"));
|
||||||
}, 500);
|
// }, 500);
|
||||||
|
|
||||||
} catch (sqlError) {
|
// } catch (sqlError) {
|
||||||
console.error("SQLite Error:", sqlError);
|
// console.error("SQLite Error:", sqlError);
|
||||||
toast.error("Gagal simpan lokal");
|
// toast.error("Gagal simpan lokal");
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+13
-9
@@ -23,13 +23,18 @@ export default function Farmers() {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
let serverData;
|
let serverData;
|
||||||
try {
|
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) {
|
} 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 localData = await OfflineService.getEntities("farmers");
|
||||||
const combined = new Map();
|
const combined = new Map();
|
||||||
|
console.log("Data Local => ", localData);
|
||||||
safeArray(serverData).forEach(f => combined.set(f.id, f));
|
safeArray(serverData).forEach(f => combined.set(f.id, f));
|
||||||
safeArray(localData).forEach(f => combined.set(f.id, f));
|
safeArray(localData).forEach(f => combined.set(f.id, f));
|
||||||
|
|
||||||
@@ -50,16 +55,15 @@ export default function Farmers() {
|
|||||||
farmer.full_name?.toLowerCase().includes(search.toLowerCase()) || farmer.nama?.toLowerCase().includes(search.toLowerCase()) ||
|
farmer.full_name?.toLowerCase().includes(search.toLowerCase()) || farmer.nama?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
farmer.nik?.includes(search) || farmer.kk?.includes(search) || farmer.ktp?.includes(search) ||
|
farmer.nik?.includes(search) || farmer.kk?.includes(search) || farmer.ktp?.includes(search) ||
|
||||||
farmer.farmer_group?.toLowerCase().includes(search.toLowerCase());
|
farmer.farmer_group?.toLowerCase().includes(search.toLowerCase());
|
||||||
|
|
||||||
const currentStatus = farmer.user.email_verified_at !== null || farmer.user.email_verified_at !== undefined ? 'verified' : 'pending';
|
|
||||||
|
|
||||||
const matchStatus = statusFilter === "all" || currentStatus === statusFilter;
|
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 matchRegion = regionFilter === "all" || farmer.regency === regionFilter;
|
|
||||||
console.log("status filter => ", matchStatus, farmer.user.email_verified_at, statusFilter);
|
|
||||||
return matchSearch && matchStatus && matchRegion;
|
return matchSearch && matchStatus && matchRegion;
|
||||||
});
|
});
|
||||||
|
console.log("Filtered Farmers => ", filteredFarmers);
|
||||||
return (
|
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="max-w-7xl mx-auto p-6 space-y-6">
|
<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 GPSLandMapper from "@/components/lands/GPSLandMapper";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Edit, MapPin, User, Maximize2, TreePine, Loader2,
|
ArrowLeft, Edit, MapPin, User, Maximize2, TreePine, Loader2,
|
||||||
Plus, CheckCircle, AlertTriangle, Clock, XCircle
|
Plus, CheckCircle, AlertTriangle, Clock, XCircle, History
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { OfflineService } from "@/components/common/offlineStorage";
|
import { OfflineService } from "@/components/common/offlineStorage";
|
||||||
import { entity } from "@/api/entities";
|
import { entity } from "@/api/entities";
|
||||||
|
import { forEach } from "lodash";
|
||||||
|
import { da } from "date-fns/locale";
|
||||||
|
|
||||||
const validationColors = {
|
const validationColors = {
|
||||||
valid: { bg: "bg-emerald-50", border: "border-emerald-200", text: "text-emerald-700", icon: CheckCircle },
|
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 = [];
|
let serverData = [];
|
||||||
try {
|
try {
|
||||||
const respon = await entity('map','lahan').findOne(id);
|
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 (respon && !respon.error && typeof respon !== 'string') {
|
||||||
if (found) return {
|
if (found) return {
|
||||||
@@ -145,6 +147,22 @@ export default function LandDetail() {
|
|||||||
initialData: null
|
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({
|
const { data: farmer } = useQuery({
|
||||||
queryKey: ["farmer", land?.farmer_id],
|
queryKey: ["farmer", land?.farmer_id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -165,9 +183,10 @@ export default function LandDetail() {
|
|||||||
try {
|
try {
|
||||||
const response = await entity('map', 'tanaman').list({ q: JSON.stringify({ land_id: id }) });
|
const response = await entity('map', 'tanaman').list({ q: JSON.stringify({ land_id: id }) });
|
||||||
const rawTanaman = Array.isArray(response.data.data) ? response.data.data : [];
|
const rawTanaman = Array.isArray(response.data.data) ? response.data.data : [];
|
||||||
|
|
||||||
if(rawTanaman && rawTanaman.length > 0){
|
if(rawTanaman && rawTanaman.length > 0){
|
||||||
const retPlants = rawTanaman.filter((data) => data.lahan_id === land.id)
|
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{
|
}else{
|
||||||
console.log("tidak ada")
|
console.log("tidak ada")
|
||||||
}
|
}
|
||||||
@@ -184,23 +203,22 @@ export default function LandDetail() {
|
|||||||
queryKey: ["inspections", id],
|
queryKey: ["inspections", id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const response = await base44.entities.PlantInspection.list();
|
const response = await entity('map', 'inspeksi/tanaman').list({ q: JSON.stringify({ land_id: id }) });
|
||||||
return Array.isArray(response) ? response : [];
|
return Array.isArray(response.data) ? response.data : Array.isArray(response.data.data) ? response.data.data : [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ambil dari Dexie jika ada
|
// 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 : [];
|
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 combinePlants = useMemo(() => {
|
||||||
const latestPlantInspection = new Map();
|
const latestPlantInspection = new Map();
|
||||||
|
|
||||||
inspections.forEach((ins) => {
|
inspections.forEach((ins) => {
|
||||||
|
|
||||||
const key = `${ins.farmer_id}_${ins.land_id}_${ins.plant_id}`;
|
const key = `${ins.farmer_id}_${ins.land_id}_${ins.plant_id}`;
|
||||||
|
|
||||||
if (!latestPlantInspection.has(key) ||
|
if (!latestPlantInspection.has(key) ||
|
||||||
@@ -301,6 +319,7 @@ export default function LandDetail() {
|
|||||||
const statusConfig = validationColors[land.validation_status] || validationColors.pending;
|
const statusConfig = validationColors[land.validation_status] || validationColors.pending;
|
||||||
const StatusIcon = statusConfig.icon;
|
const StatusIcon = statusConfig.icon;
|
||||||
const village = villages.find((dt) => dt.id === land.desa_kelurahan_id);
|
const village = villages.find((dt) => dt.id === land.desa_kelurahan_id);
|
||||||
|
console.log("land inspections => ", land);
|
||||||
return (
|
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="max-w-6xl mx-auto p-6 space-y-6">
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
@@ -317,8 +336,8 @@ export default function LandDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">{land.nama}</h1>
|
<h1 className="text-xl font-bold text-slate-900">{land.nama || land.name}</h1>
|
||||||
<p className="text-slate-500">{village?.nama || '-'}</p>
|
<p className="text-slate-500">{land?.desa_kelurahan?.nama || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -398,7 +417,7 @@ export default function LandDetail() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Nama/Label Lahan</Label>
|
<Label>Nama/Label Lahan</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.name}
|
value={formData.name || formData.nama}
|
||||||
onChange={(e) => handleChange("name", e.target.value)}
|
onChange={(e) => handleChange("name", e.target.value)}
|
||||||
placeholder="Contoh: Kebun Kopi Utara"
|
placeholder="Contoh: Kebun Kopi Utara"
|
||||||
required
|
required
|
||||||
@@ -523,8 +542,7 @@ export default function LandDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500">Lokasi</p>
|
<p className="text-xs text-slate-500">Lokasi</p>
|
||||||
<p className="font-medium text-slate-900">
|
<p className="font-medium text-slate-900">
|
||||||
{village?.nama}, {land.district}<br/>
|
{land?.desa_kelurahan?.nama}, {land?.desa_kelurahan?.kecamatan?.nama}<br/>
|
||||||
{land.regency}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -622,6 +640,37 @@ export default function LandDetail() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</motion.div>
|
||||||
|
|
||||||
{/* Right Column - Map */}
|
{/* Right Column - Map */}
|
||||||
|
|||||||
+160
-51
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import base44 from "@/api/Client";
|
import base44 from "@/api/Client";
|
||||||
@@ -21,22 +21,12 @@ export default function LandRegister() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const preselectedFarmerId = urlParams.get("farmer_id");
|
const preselectedFarmerId = urlParams.get("farmer_id");
|
||||||
|
const [locationData, setLocationData ] = useState({
|
||||||
// const [formData, setFormData] = useState({
|
provinces: [],
|
||||||
// farmer_id: preselectedFarmerId || "",
|
regencies: [],
|
||||||
// name: "",
|
districts: [],
|
||||||
// land_status: "owned",
|
villages: []
|
||||||
// village: "",
|
});
|
||||||
// district: "",
|
|
||||||
// regency: "",
|
|
||||||
// polygon_coordinates: null,
|
|
||||||
// center_lat: null,
|
|
||||||
// center_lng: null,
|
|
||||||
// area_hectares: null,
|
|
||||||
// validation_status: "pending",
|
|
||||||
// sync_status: "pending"
|
|
||||||
// });
|
|
||||||
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
nama : '',
|
nama : '',
|
||||||
@@ -52,29 +42,31 @@ export default function LandRegister() {
|
|||||||
deleted_by : '',
|
deleted_by : '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// const { data: farmers = [] } = useQuery({
|
|
||||||
// queryKey: ["farmers"],
|
|
||||||
// queryFn: () => base44.entities.Farmer.list()
|
|
||||||
// });
|
|
||||||
|
|
||||||
const { data: rawFarmers = [] } = useQuery({
|
const { data: rawFarmers = [] } = useQuery({
|
||||||
queryKey: ["farmers"],
|
queryKey: ["farmers"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
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
|
// 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
|
// Gabungkan dengan data lokal dari Dexie
|
||||||
const localData = await OfflineService.getEntities("farmers");
|
const localData = await OfflineService.getEntities("farmers");
|
||||||
|
|
||||||
const combined = new Map();
|
const combined = {};
|
||||||
serverData.forEach(f => combined.set(f.id, f));
|
serverData.forEach(f => {
|
||||||
localData.forEach(f => combined.set(f.id, 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) {
|
} 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");
|
return await OfflineService.getEntities("farmers");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,16 +158,22 @@ export default function LandRegister() {
|
|||||||
// };
|
// };
|
||||||
|
|
||||||
const handlePolygonSave = (polygonData) => {
|
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)
|
console.log(cleanCoordinates)
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
// polygon_coordinates: cleanCoordinates,
|
// polygon_coordinates: cleanCoordinates,
|
||||||
path : JSON.stringify(cleanCoordinates),
|
path : JSON.stringify(coords),
|
||||||
center_lat: Number(polygonData.center_lat),
|
center_lat: Number(polygonData.center_lat) || 0,
|
||||||
center_lng: Number(polygonData.center_lng),
|
center_lng: Number(polygonData.center_lng) || 0,
|
||||||
area_hectares: Number(polygonData.area_hectares),
|
area_hectares: Number(polygonData.area_hectares) || 0,
|
||||||
luas_lahan: polygonData.area_hectares
|
luas_lahan: polygonData.area_hectares || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
toast.success(`Polygon disimpan! Luas: ${polygonData.area_hectares.toFixed(4)} Ha`);
|
toast.success(`Polygon disimpan! Luas: ${polygonData.area_hectares.toFixed(4)} Ha`);
|
||||||
@@ -183,13 +181,16 @@ export default function LandRegister() {
|
|||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
console.log("Submission start");
|
||||||
|
|
||||||
if (!formData.farmer_id) {
|
if (!formData.farmer_id) {
|
||||||
|
console.log("No Farmer selected");
|
||||||
toast.error("Pilih petani terlebih dahulu");
|
toast.error("Pilih petani terlebih dahulu");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.center_lat || !formData.center_lng) {
|
if (!formData.center_lat || !formData.center_lng) {
|
||||||
|
console.log("No center lat long");
|
||||||
toast.error("Gambar polygon lahan di peta");
|
toast.error("Gambar polygon lahan di peta");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,9 +201,79 @@ export default function LandRegister() {
|
|||||||
// Auto-fill address from farmer
|
// Auto-fill address from farmer
|
||||||
// const selectedFarmer = farmers.find(f => f.id === formData.farmer_id);
|
// const selectedFarmer = farmers.find(f => f.id === formData.farmer_id);
|
||||||
const selectedFarmer = Array.isArray(farmers)
|
const selectedFarmer = Array.isArray(farmers)
|
||||||
? farmers.find(f => f.id === formData.farmer_id)
|
? farmers.find(f => Number(f.id) === Number(formData.farmer_id))
|
||||||
: null;
|
: 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 (
|
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="max-w-5xl mx-auto p-6 space-y-6">
|
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||||
@@ -247,7 +318,7 @@ export default function LandRegister() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{farmers.map(farmer => (
|
{farmers.map(farmer => (
|
||||||
<SelectItem key={farmer.id} value={farmer.id}>
|
<SelectItem key={farmer.id} value={farmer.id}>
|
||||||
{farmer.full_name} - {farmer.village}, {farmer.district}
|
{farmer.nama} - {farmer.alamat}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -300,28 +371,66 @@ export default function LandRegister() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Desa</Label>
|
<Label>Desa</Label>
|
||||||
<Input
|
<Select
|
||||||
value={formData.desa_kelurahan_id}
|
name="desa_kelurahan_id"
|
||||||
onChange={(e) => handleChange("desa_kelurahan_id", e.target.value)}
|
value={formData?.desa_kelurahan_id?.toString()}
|
||||||
placeholder="Nama desa"
|
onValueChange={(e) => setFormData((prev) => ({
|
||||||
className="h-11"
|
...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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Kecamatan</Label>
|
<Label>Kecamatan</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.district}
|
id="kecamatan"
|
||||||
onChange={(e) => handleChange("district", e.target.value)}
|
value={locationData.districts[0]?.nama || "-"}
|
||||||
|
onChange={(e) => handleChange("kecamatan_id", e.target.value)}
|
||||||
placeholder="Nama kecamatan"
|
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"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Kabupaten</Label>
|
<Label htmlFor="provinsi">Provinsi</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.regency}
|
id="provinsi"
|
||||||
onChange={(e) => handleChange("regency", e.target.value)}
|
value={locationData.provinces[0]?.nama || "-"}
|
||||||
placeholder="Nama kabupaten"
|
onChange={(e) => handleChange("provinsi", e.target.value)}
|
||||||
|
placeholder="Nama Provinsi"
|
||||||
|
required
|
||||||
className="h-11"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+78
-41
@@ -13,6 +13,7 @@
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Map as MapIcon, Plus, Search, Filter, LayoutGrid, MapPinned } from "lucide-react";
|
import { Map as MapIcon, Plus, Search, Filter, LayoutGrid, MapPinned } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { moodConfig } from "@/utils/moodColor";
|
||||||
import { OfflineService } from "@/components/common/offlineStorage";
|
import { OfflineService } from "@/components/common/offlineStorage";
|
||||||
|
|
||||||
export default function Lands() {
|
export default function Lands() {
|
||||||
@@ -20,46 +21,81 @@ import { OfflineService } from "@/components/common/offlineStorage";
|
|||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [view, setView] = useState("grid");
|
const [view, setView] = useState("grid");
|
||||||
|
|
||||||
// const { data: rawlands = [], isLoading } = useQuery({
|
|
||||||
// queryKey: ["lands"],
|
|
||||||
// queryFn: () => base44.entities.Land.list("-created_date")
|
|
||||||
// });
|
|
||||||
const { data: rawlands = [], isLoading } = useQuery({
|
const { data: rawlands = [], isLoading } = useQuery({
|
||||||
queryKey: ["lands"],
|
queryKey: ["lands"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
let serverData = [];
|
let serverData = [];
|
||||||
try {
|
let serverInspections = [];
|
||||||
// Tambahkan timeout atau handle jika return HTML
|
let rawInspections = [];
|
||||||
const resp = await entity("map","lahan").list();
|
|
||||||
console.log(resp)
|
const fetchAllLands = async () => {
|
||||||
if (Array.isArray(resp)) serverData = resp;
|
let allLands = [];
|
||||||
} catch (e) {
|
let currentPage =1;
|
||||||
console.warn("Server lands offline/error : ", e);
|
let hasNextPage = true;
|
||||||
}
|
|
||||||
|
while (hasNextPage) {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 localData = await OfflineService.getEntities("lands");
|
||||||
|
|
||||||
const combined = new Map();
|
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); });
|
serverData.forEach(l => { if(l.id) combined.set(l.id, l); });
|
||||||
localData.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());
|
const result = Array.from(combined.values());
|
||||||
// console.log("🔍 Total Lands Loaded (Server + Local):", result.length);
|
try{
|
||||||
return result;
|
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({
|
const { data: rawfarmers = [], isFarmerLoading } = useQuery({
|
||||||
// queryKey: ["farmers"],
|
|
||||||
// queryFn: () => base44.entities.Farmer.list()
|
|
||||||
// });
|
|
||||||
|
|
||||||
const { data: rawfarmers = [] } = useQuery({
|
|
||||||
queryKey: ["farmers"],
|
queryKey: ["farmers"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
let serverData = [];
|
let serverData = [];
|
||||||
try {
|
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 : [];
|
serverData = Array.isArray(resp?.data) ? resp.data : Array.isArray(resp?.data?.data) ? resp?.data?.data : [];
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
const localData = await OfflineService.getEntities("farmers");
|
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"],
|
queryKey: ["plants"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const rawplants = await entity('map','tanaman').list();
|
const rawplants = await entity('map','tanaman').list();
|
||||||
const plants = Array.isArray(rawplants?.data) ? rawplants?.data : Array.isArray(rawplants?.data?.data) ? rawplants?.data?.data : [];
|
const plants = Array.isArray(rawplants?.data) ? rawplants?.data : Array.isArray(rawplants?.data?.data) ? rawplants?.data?.data : [];
|
||||||
console.log(plants, "Tanamana")
|
|
||||||
return plants
|
return plants
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -92,17 +128,6 @@ import { OfflineService } from "@/components/common/offlineStorage";
|
|||||||
return map;
|
return map;
|
||||||
}, [farmers]);
|
}, [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(() => {
|
const filteredLands = useMemo(() => {
|
||||||
if (!Array.isArray(lands)) return [];
|
if (!Array.isArray(lands)) return [];
|
||||||
|
|
||||||
@@ -137,10 +162,19 @@ import { OfflineService } from "@/components/common/offlineStorage";
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [plants]);
|
}, [plants]);
|
||||||
console.log(farmerMap,farmers, "FArmer MAp", plants, lands);
|
|
||||||
|
if (isLoading || isFarmerLoading || isPlantLoading) {
|
||||||
|
return (
|
||||||
|
<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 (
|
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">
|
<div className="max-w-7xl mx-auto p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -153,7 +187,7 @@ import { OfflineService } from "@/components/common/offlineStorage";
|
|||||||
<p className="text-slate-500">{lands.length} lahan terdaftar</p>
|
<p className="text-slate-500">{lands.length} lahan terdaftar</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to={createPageUrl("LandRegister")}>
|
<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" />
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
Tambah Lahan
|
Tambah Lahan
|
||||||
</Button>
|
</Button>
|
||||||
@@ -237,7 +271,9 @@ import { OfflineService } from "@/components/common/offlineStorage";
|
|||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
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
|
<motion.div
|
||||||
key={land.id}
|
key={land.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -247,10 +283,11 @@ import { OfflineService } from "@/components/common/offlineStorage";
|
|||||||
<LandCard
|
<LandCard
|
||||||
land={land}
|
land={land}
|
||||||
farmerName={farmerMap[land.profile_id]}
|
farmerName={farmerMap[land.profile_id]}
|
||||||
|
lastInspection={land.inspections?.[0]}
|
||||||
plantCount={plants.filter(p => p.lahan.id === land.id).length}
|
plantCount={plants.filter(p => p.lahan.id === land.id).length}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
)})}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import EmptyState from "@/components/common/EmptyState";
|
import EmptyState from "@/components/common/EmptyState";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import { toast } from "sonner";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { id } from "date-fns/locale";
|
import { id } from "date-fns/locale";
|
||||||
|
import { entity } from "@/api/entities";
|
||||||
|
|
||||||
const gradeConfig = {
|
const gradeConfig = {
|
||||||
A: { label: "Grade A", color: "bg-emerald-100 text-emerald-700", description: "Kualitas Premium" },
|
A: { label: "Grade A", color: "bg-emerald-100 text-emerald-700", description: "Kualitas Premium" },
|
||||||
@@ -41,12 +42,17 @@ export default function OfftakerPortal() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
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);
|
setUser(currentUser);
|
||||||
|
|
||||||
const offtakers = await base44.entities.Offtaker.filter({ user_email: currentUser.email });
|
const offtakers = await entity("map","offtaker").find({ user_email: currentUser.email });
|
||||||
if (offtakers.length > 0 && offtakers[0].is_active) {
|
// const offtakers = await entity("auth", "profile").list({ user_email: currentUser.email });
|
||||||
setOfftaker(offtakers[0]);
|
const filteredOfftakers = offtakers?.data ? offtakers.data : [];
|
||||||
|
|
||||||
|
if (filteredOfftakers && filteredOfftakers[0].is_active) {
|
||||||
|
setOfftaker(filteredOfftakers[0]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Not logged in
|
// Not logged in
|
||||||
@@ -56,32 +62,50 @@ export default function OfftakerPortal() {
|
|||||||
};
|
};
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load harvests pending grading
|
//get pending harvests that are sssigned to this offtaker
|
||||||
const { data: pendingHarvests = [] } = useQuery({
|
const { data: pendingHarvests = [] } = useQuery({
|
||||||
queryKey: ["pending-harvests"],
|
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
|
enabled: !!offtaker
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load graded harvests by this offtaker
|
// Load graded harvests by this offtaker
|
||||||
const { data: gradedHarvests = [] } = useQuery({
|
const { data: gradedHarvests = [] } = useQuery({
|
||||||
queryKey: ["graded-harvests", offtaker?.user_email],
|
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
|
enabled: !!offtaker
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load pending distributions for this offtaker
|
// Load pending distributions for this offtaker
|
||||||
const { data: pendingDistributions = [] } = useQuery({
|
const { data: pendingDistributions = [] } = useQuery({
|
||||||
queryKey: ["pending-distributions", offtaker?.id],
|
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
|
enabled: !!offtaker?.id
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load graded distributions
|
// Load graded distributions
|
||||||
const { data: gradedDistributions = [] } = useQuery({
|
const { data: gradedDistributions = [] } = useQuery({
|
||||||
queryKey: ["graded-distributions", offtaker?.id],
|
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
|
enabled: !!offtaker?.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,11 +119,14 @@ export default function OfftakerPortal() {
|
|||||||
// Grade mutation
|
// Grade mutation
|
||||||
const gradeMutation = useMutation({
|
const gradeMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const totalValue = parseFloat(gradeForm.price_per_kg) * selectedHarvest.quantity_kg;
|
const price = parseFloat(gradeForm.price_per_kg);
|
||||||
await base44.entities.Harvest.update(selectedHarvest.id, {
|
const totalValue = price * selectedHarvest.quantity_kg;
|
||||||
|
|
||||||
|
// Tambahkan return di sini
|
||||||
|
return await entity('distribusi','panen').update(selectedHarvest.id, {
|
||||||
grade: gradeForm.grade,
|
grade: gradeForm.grade,
|
||||||
grade_notes: gradeForm.notes,
|
grade_notes: gradeForm.notes,
|
||||||
price_per_kg: parseFloat(gradeForm.price_per_kg),
|
price_per_kg: price,
|
||||||
total_value: totalValue,
|
total_value: totalValue,
|
||||||
graded_by: offtaker.user_email,
|
graded_by: offtaker.user_email,
|
||||||
graded_by_name: offtaker.contact_name,
|
graded_by_name: offtaker.contact_name,
|
||||||
@@ -113,6 +140,10 @@ export default function OfftakerPortal() {
|
|||||||
setSelectedHarvest(null);
|
setSelectedHarvest(null);
|
||||||
setGradeForm({ grade: "", notes: "", price_per_kg: "" });
|
setGradeForm({ grade: "", notes: "", price_per_kg: "" });
|
||||||
toast.success("Grading berhasil disimpan!");
|
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 */}
|
{/* Tabs */}
|
||||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
|
||||||
<Tabs defaultValue="distributions" className="space-y-4">
|
<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">
|
<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" />
|
<Truck className="w-4 h-4 mr-2" />
|
||||||
Distribusi ({pendingDistributions.length})
|
Distribusi ({pendingDistributions.length})
|
||||||
@@ -412,6 +443,10 @@ export default function OfftakerPortal() {
|
|||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Grading Hasil Panen</DialogTitle>
|
<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>
|
</DialogHeader>
|
||||||
|
|
||||||
{selectedHarvest && (
|
{selectedHarvest && (
|
||||||
@@ -475,9 +510,14 @@ export default function OfftakerPortal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full h-12 bg-indigo-600 hover:bg-indigo-700"
|
type="button"
|
||||||
onClick={() => gradeMutation.mutate()}
|
// className="w-full h-12 bg-indigo-600 hover:bg-indigo-700"
|
||||||
disabled={!gradeForm.grade || !gradeForm.price_per_kg || gradeMutation.isPending}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log("Button Clicked!");
|
||||||
|
gradeMutation.mutate();
|
||||||
|
}}
|
||||||
|
// disabled={!gradeForm.grade || !gradeForm.price_per_kg || gradeMutation.isPending}
|
||||||
>
|
>
|
||||||
{gradeMutation.isPending ? (
|
{gradeMutation.isPending ? (
|
||||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
<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({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data) => base44.entities.Offtaker.create(data),
|
mutationFn: (data) => base44.entities.Offtaker.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -89,7 +100,7 @@ export default function Offtakers() {
|
|||||||
<Label>Email *</Label>
|
<Label>Email *</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.user_email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData(p => ({ ...p, user_email: e.target.value }))}
|
onChange={(e) => setFormData(p => ({ ...p, user_email: e.target.value }))}
|
||||||
placeholder="email@company.com"
|
placeholder="email@company.com"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+30
-8
@@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { motion } from "framer-motion";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { entity } from "@/api/entities";
|
import { entity } from "@/api/entities";
|
||||||
@@ -46,9 +46,30 @@ export default function Plants() {
|
|||||||
const { data: rawlands = [] } = useQuery({
|
const { data: rawlands = [] } = useQuery({
|
||||||
queryKey: ["lands"],
|
queryKey: ["lands"],
|
||||||
queryFn: async() => {
|
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 : [];
|
const fetchAllLands = async() => {
|
||||||
return data;
|
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 className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-sm text-slate-500">Hidup</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +189,7 @@ export default function Plants() {
|
|||||||
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-sm text-slate-500">Sakit</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,8 +315,9 @@ export default function Plants() {
|
|||||||
<span className="truncate">{land?.nama || land?.name || "Lahan tidak diketahui"}</span>
|
<span className="truncate">{land?.nama || land?.name || "Lahan tidak diketahui"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-slate-600">
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
<Calendar className="w-4 h-4 text-slate-400" />
|
<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.created_at ? format(new Date(plant.created_at), "dd MMM yyyy") : "-"}</span> */}
|
||||||
|
<span>{plant?.latitude || "-"}, {plant?.longitude || "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
TrendingUp, TrendingDown, Map as MapIcon, TreePine, BarChart3,
|
TrendingUp, TrendingDown, Map as MapIcon, TreePine, BarChart3,
|
||||||
AlertTriangle, CheckCircle, Leaf, Bug, Cloud, Droplets
|
AlertTriangle, CheckCircle, Leaf, Bug, Cloud, Droplets,
|
||||||
|
User
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from "recharts";
|
||||||
import { OfflineService } from "@/components/common/offlineStorage";
|
import { OfflineService } from "@/components/common/offlineStorage";
|
||||||
import { entity } from "@/api/entities";
|
import { entity } from "@/api/entities";
|
||||||
|
import { da } from "date-fns/locale";
|
||||||
|
|
||||||
const COLORS = ["#10b981", "#f59e0b", "#ef4444", "#6b7280"];
|
const COLORS = ["#10b981", "#f59e0b", "#ef4444", "#6b7280"];
|
||||||
|
|
||||||
@@ -40,10 +42,18 @@ const issueLabels = {
|
|||||||
export default function ProductivityMonitoring() {
|
export default function ProductivityMonitoring() {
|
||||||
const [filterLevel, setFilterLevel] = useState("regency");
|
const [filterLevel, setFilterLevel] = useState("regency");
|
||||||
const [selectedRegion, setSelectedRegion] = useState("all");
|
const [selectedRegion, setSelectedRegion] = useState("all");
|
||||||
|
const [isFarmer, setIsFarmer] = useState(false);
|
||||||
|
|
||||||
// Reset selected region when filter level changes
|
// Reset selected region when filter level changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedRegion("all");
|
setSelectedRegion("all");
|
||||||
|
|
||||||
|
const checkStatusUser = async () => {
|
||||||
|
const check = await checkIsFarmer();
|
||||||
|
setIsFarmer(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkStatusUser();
|
||||||
}, [filterLevel]);
|
}, [filterLevel]);
|
||||||
|
|
||||||
const getMe = async() => {
|
const getMe = async() => {
|
||||||
@@ -52,24 +62,67 @@ export default function ProductivityMonitoring() {
|
|||||||
return me;
|
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({
|
const {data: rawLands = []} = useQuery({
|
||||||
queryKey: ['lands'],
|
queryKey: ['lands'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
let me;
|
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 dataServer = [];
|
||||||
|
let localData = [];
|
||||||
try{
|
try{
|
||||||
me = await getMe();
|
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){
|
}catch(err){
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
const localRes = await OfflineService.getEntities('lands');
|
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();
|
const combined = new Map();
|
||||||
dataServer.forEach(d => {
|
dataServer.forEach(d => {
|
||||||
@@ -84,7 +137,6 @@ export default function ProductivityMonitoring() {
|
|||||||
}catch(err){
|
}catch(err){
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(combined.values());
|
return Array.from(combined.values());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -105,9 +157,12 @@ export default function ProductivityMonitoring() {
|
|||||||
let me;
|
let me;
|
||||||
try{
|
try{
|
||||||
me = await getMe();
|
me = await getMe();
|
||||||
console.log(me, "Med")
|
|
||||||
const res = await entity('map', 'tanaman').list();
|
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){}
|
}catch(e){}
|
||||||
let local = await OfflineService.getEntities('plants');
|
let local = await OfflineService.getEntities('plants');
|
||||||
local = Array.isArray(local) ? local.filter((l) => l.profile_id === me.id) : [];
|
local = Array.isArray(local) ? local.filter((l) => l.profile_id === me.id) : [];
|
||||||
@@ -124,22 +179,29 @@ export default function ProductivityMonitoring() {
|
|||||||
|
|
||||||
const lands = Array.isArray(rawLands) ? rawLands : [];
|
const lands = Array.isArray(rawLands) ? rawLands : [];
|
||||||
const plants = Array.isArray(rawPlants) ? rawPlants : [];
|
const plants = Array.isArray(rawPlants) ? rawPlants : [];
|
||||||
|
|
||||||
const { data: rawHarvests = [] } = useQuery({
|
const { data: rawHarvests = [] } = useQuery({
|
||||||
queryKey: ["harvests"],
|
queryKey: ["harvests"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
let me;
|
||||||
try {
|
try {
|
||||||
|
me = await getMe();
|
||||||
const res = await entity('map', 'panen').list();
|
const res = await entity('map', 'panen').list();
|
||||||
let harvests = res?.data || res?.data?.data || [];
|
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) {
|
} catch (e) {
|
||||||
|
console.error("Error => ", e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const harvests = Array.isArray(rawHarvests) ? rawHarvests : [];
|
const harvests = Array.isArray(rawHarvests) ? rawHarvests : [];
|
||||||
|
|
||||||
// Get unique regions
|
// Get unique regions
|
||||||
const regions = useMemo(() => ({
|
const regions = useMemo(() => ({
|
||||||
regency: [...new Set(lands.map(l => l.regency).filter(Boolean))],
|
regency: [...new Set(lands.map(l => l.regency).filter(Boolean))],
|
||||||
@@ -167,14 +229,14 @@ export default function ProductivityMonitoring() {
|
|||||||
? lands
|
? lands
|
||||||
: filterLevel === "village"
|
: filterLevel === "village"
|
||||||
? lands.filter(l => {
|
? lands.filter(l => {
|
||||||
const villageName = l.village || l.village_name || l.desa || l.village_id;
|
const villageName = l.village || l.village_name || l.desa || l.village_id || l.desa_kelurahan.nama;
|
||||||
const villageId = l.village_id || l.village;
|
const villageId = l.village_id || l.village || l.desa_kelurahan.id;
|
||||||
return villageName === selectedRegion || villageId === selectedRegion;
|
return villageName === selectedRegion || villageId === selectedRegion;
|
||||||
})
|
})
|
||||||
: lands.filter(l => l[filterLevel] === selectedRegion);
|
: lands.filter(l => l[filterLevel] === selectedRegion);
|
||||||
|
|
||||||
const filteredLandIds = filteredLands.map(l => l.id);
|
const filteredLandIds = filteredLands.length > 0 ? filteredLands.map(l => l.id) : [];
|
||||||
const filteredPlants = plants.filter(p => filteredLandIds.includes(p.land_id || p.lahan_id));
|
const filteredPlants = plants.length > 0 ? plants.filter(p => filteredLandIds.includes(p?.lahan?.id || p?.lahan_id)) : [];
|
||||||
|
|
||||||
const calculateProductivityStats = () => {
|
const calculateProductivityStats = () => {
|
||||||
const totalLands = filteredLands.length;
|
const totalLands = filteredLands.length;
|
||||||
@@ -183,11 +245,22 @@ export default function ProductivityMonitoring() {
|
|||||||
const landsWithProductivity = filteredLands.filter(l => l.productivity_percentage !== undefined);
|
const landsWithProductivity = filteredLands.filter(l => l.productivity_percentage !== undefined);
|
||||||
const landBasedAvg = landsWithProductivity.length > 0
|
const landBasedAvg = landsWithProductivity.length > 0
|
||||||
? landsWithProductivity.reduce((sum, l) => sum + (l.productivity_percentage || 0), 0) / landsWithProductivity.length
|
? landsWithProductivity.reduce((sum, l) => sum + (l.productivity_percentage || 0), 0) / landsWithProductivity.length
|
||||||
: 0;
|
: 10;
|
||||||
|
|
||||||
// Calculate harvest-based productivity
|
// Calculate harvest-based productivity
|
||||||
const harvestProductivity = calculateHarvestBasedProductivity();
|
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)
|
// Combined average (weighted)
|
||||||
const combinedAvg = landsWithProductivity.length > 0 && harvestProductivity.totalHarvests > 0
|
const combinedAvg = landsWithProductivity.length > 0 && harvestProductivity.totalHarvests > 0
|
||||||
? (landBasedAvg + harvestProductivity.avg) / 2
|
? (landBasedAvg + harvestProductivity.avg) / 2
|
||||||
@@ -196,12 +269,12 @@ export default function ProductivityMonitoring() {
|
|||||||
: harvestProductivity.avg;
|
: harvestProductivity.avg;
|
||||||
|
|
||||||
const distribution = [
|
const distribution = [
|
||||||
{ name: "Sangat Produktif (>80%)", value: filteredLands.filter(l => (l.productivity_percentage || 0) > 80).length, color: "#10b981" },
|
{ name: "Sangat Produktif (>80%)", value: landsWithCalculatedProductivity.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: "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: filteredLands.filter(l => (l.productivity_percentage || 0) >= 40 && (l.productivity_percentage || 0) < 60).length, color: "#f59e0b" },
|
{ 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: filteredLands.filter(l => (l.productivity_percentage || 0) < 40).length, color: "#ef4444" }
|
{ name: "Tidak Produktif (<40%)", value: landsWithCalculatedProductivity.filter(l => (l.productivity_percentage || 0) < 40).length, color: "#ef4444" }
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
avg: landBasedAvg,
|
avg: landBasedAvg,
|
||||||
distribution,
|
distribution,
|
||||||
@@ -213,7 +286,7 @@ export default function ProductivityMonitoring() {
|
|||||||
|
|
||||||
// Calculate harvest-based productivity
|
// Calculate harvest-based productivity
|
||||||
const calculateHarvestBasedProductivity = () => {
|
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) {
|
if (filteredHarvests.length === 0) {
|
||||||
return { avg: 0, totalHarvests: 0, totalWeight: 0, landProductivity: [] };
|
return { avg: 0, totalHarvests: 0, totalWeight: 0, landProductivity: [] };
|
||||||
@@ -224,7 +297,7 @@ export default function ProductivityMonitoring() {
|
|||||||
if (!acc[harvest.land_id]) {
|
if (!acc[harvest.land_id]) {
|
||||||
acc[harvest.land_id] = { totalWeight: 0, harvestCount: 0 };
|
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++;
|
acc[harvest.land_id].harvestCount++;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -290,16 +363,34 @@ export default function ProductivityMonitoring() {
|
|||||||
// Calculate regional productivity
|
// Calculate regional productivity
|
||||||
const calculateRegionalProductivity = () => {
|
const calculateRegionalProductivity = () => {
|
||||||
const groupKey = filterLevel === "regency" ? "regency" : filterLevel === "district" ? "district" : "village";
|
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 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]) {
|
if (!acc[region]) {
|
||||||
acc[region] = { totalLands: 0, totalArea: 0, totalProductivity: 0, landsWithProductivity: 0 };
|
acc[region] = { totalLands: 0, totalArea: 0, totalProductivity: 0, landsWithProductivity: 0 };
|
||||||
}
|
}
|
||||||
acc[region].totalLands++;
|
acc[region].totalLands++;
|
||||||
acc[region].totalArea += land.area_hectares || 0;
|
acc[region].totalArea += parseFloat(land.luas_lahan) || 0;
|
||||||
if (land.productivity_percentage !== undefined) {
|
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++;
|
acc[region].landsWithProductivity++;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
@@ -318,7 +409,7 @@ export default function ProductivityMonitoring() {
|
|||||||
const productivityStats = calculateProductivityStats();
|
const productivityStats = calculateProductivityStats();
|
||||||
const plantStats = calculatePlantStats();
|
const plantStats = calculatePlantStats();
|
||||||
const regionalData = calculateRegionalProductivity();
|
const regionalData = calculateRegionalProductivity();
|
||||||
|
|
||||||
// Grade distribution from harvests
|
// Grade distribution from harvests
|
||||||
const gradeDistribution = harvests.reduce((acc, h) => {
|
const gradeDistribution = harvests.reduce((acc, h) => {
|
||||||
if (h.grade) {
|
if (h.grade) {
|
||||||
@@ -331,9 +422,36 @@ export default function ProductivityMonitoring() {
|
|||||||
name: grade === "rejected" ? "Rejected" : `Grade ${grade}`,
|
name: grade === "rejected" ? "Rejected" : `Grade ${grade}`,
|
||||||
value: gradeDistribution[grade] || 0
|
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 (
|
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">
|
<div className="max-w-7xl mx-auto p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* 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">
|
<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">
|
<div className="flex-1">
|
||||||
<p className="text-sm text-slate-500">Produktivitas Panen</p>
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -470,7 +588,7 @@ export default function ProductivityMonitoring() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm text-slate-500">Rata-rata Hasil/Ha</p>
|
<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>
|
<p className="text-xs text-slate-400">Total produktivitas per hektar</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export default function ValidatorPortal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="lands" className="w-full">
|
<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="lands">Validasi Lahan</TabsTrigger>
|
||||||
<TabsTrigger value="farmers">Validasi Petani</TabsTrigger>
|
<TabsTrigger value="farmers">Validasi Petani</TabsTrigger>
|
||||||
<TabsTrigger value="history">Riwayat</TabsTrigger>
|
<TabsTrigger value="history">Riwayat</TabsTrigger>
|
||||||
|
|||||||
+11
-11
@@ -42,9 +42,10 @@ export default function Validators() {
|
|||||||
queryKey: ["provinces"],
|
queryKey: ["provinces"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try{
|
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)) {
|
if (data && Array.isArray(data)) {
|
||||||
return data;
|
return dataArray;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}catch(e){
|
}catch(e){
|
||||||
@@ -58,9 +59,9 @@ export default function Validators() {
|
|||||||
queryKey: ["validators"],
|
queryKey: ["validators"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try{
|
try{
|
||||||
const res = await entity('manajemen', 'fasilitator').list();
|
const res = await entity('auth', 'profile').list();
|
||||||
console.log(res.data.data)
|
const ret =Array.isArray(res?.data) ? res?.data : Array.isArray(res?.data.data) ? res.data.data : [];
|
||||||
return Array.isArray(res?.data) ? res?.data : Array.isArray(res?.data.data) ? res.data.data : [];
|
return ret.filter(data => data.user?.role === 'fasilitator');
|
||||||
}catch(ex){
|
}catch(ex){
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ export default function Validators() {
|
|||||||
if(typeof actServer === 'string' && actServer.includes('<!doctype html')){
|
if(typeof actServer === 'string' && actServer.includes('<!doctype html')){
|
||||||
throw new Error('Data is HTML file, not a good responds');
|
throw new Error('Data is HTML file, not a good responds');
|
||||||
}
|
}
|
||||||
return actServer;
|
return actServer.data?.data || actServer?.data;
|
||||||
}catch(err){
|
}catch(err){
|
||||||
throw {type: 'OFFLINE_SAVE', data}
|
throw {type: 'OFFLINE_SAVE', data}
|
||||||
}
|
}
|
||||||
@@ -144,7 +145,7 @@ export default function Validators() {
|
|||||||
<Label>Email *</Label>
|
<Label>Email *</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.user_email}
|
value={formData.user_email || formData.email}
|
||||||
onChange={(e) => setFormData(p => ({ ...p, user_email: e.target.value }))}
|
onChange={(e) => setFormData(p => ({ ...p, user_email: e.target.value }))}
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
/>
|
/>
|
||||||
@@ -249,8 +250,8 @@ export default function Validators() {
|
|||||||
<Shield className="w-5 h-5 text-blue-600" />
|
<Shield className="w-5 h-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-slate-900">{v.full_name}</p>
|
<p className="font-semibold text-slate-900">{v.full_name || v.nama}</p>
|
||||||
<Badge className="bg-blue-100 text-blue-700 text-xs">{roleLabels[v.role]}</Badge>
|
<Badge className="bg-blue-100 text-blue-700 text-xs">{v.user?.role}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -260,8 +261,7 @@ export default function Validators() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm text-slate-600">
|
<div className="space-y-2 text-sm text-slate-600">
|
||||||
<p className="flex items-center gap-2"><MapPin className="w-4 h-4" />
|
<p className="flex items-center gap-2"><MapPin className="w-4 h-4" />
|
||||||
{v.village}, {v.district}, {v.regency},
|
{v.alamat || '......'}
|
||||||
{provinces.find(p => p.id === v.province)?.nama}
|
|
||||||
</p>
|
</p>
|
||||||
{v.phone && <p className="flex items-center gap-2"><Phone className="w-4 h-4" /> {v.phone}</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>
|
<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',
|
profiles: 'id,user_id, kk,ktp,email, sync_status, name, nama',
|
||||||
harvests: 'id, plant_id, land_id,farmer_id, sync_status',
|
harvests: 'id, plant_id, land_id,farmer_id, sync_status',
|
||||||
plant_inspections: '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',
|
distributions: 'id, farmer_id,offtaker_id, sync_status',
|
||||||
villages: 'id, name',
|
villages: 'id, name',
|
||||||
districts: '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"),
|
"@": 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: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
open: true,
|
open: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user