mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 14:04:08 +01:00
ui: improve farms+crops list page
This commit is contained in:
parent
b1c8b50a86
commit
9605aa740b
@ -1,6 +1,11 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Sprout, Calendar } from "lucide-react";
|
||||
import { Crop } from "@/types";
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card";
|
||||
import { Sprout, Calendar, ArrowRight, BarChart } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { Crop } from "@/types";
|
||||
|
||||
interface CropCardProps {
|
||||
crop: Crop;
|
||||
@ -9,32 +14,85 @@ interface CropCardProps {
|
||||
|
||||
export function CropCard({ crop, onClick }: CropCardProps) {
|
||||
const statusColors = {
|
||||
growing: "text-green-500",
|
||||
harvested: "text-yellow-500",
|
||||
planned: "text-blue-500",
|
||||
growing: {
|
||||
bg: "bg-green-50 dark:bg-green-900",
|
||||
text: "text-green-600 dark:text-green-300",
|
||||
border: "border-green-200",
|
||||
},
|
||||
harvested: {
|
||||
bg: "bg-yellow-50 dark:bg-yellow-900",
|
||||
text: "text-yellow-600 dark:text-yellow-300",
|
||||
border: "border-yellow-200",
|
||||
},
|
||||
planned: {
|
||||
bg: "bg-blue-50 dark:bg-blue-900",
|
||||
text: "text-blue-600 dark:text-blue-300",
|
||||
border: "border-blue-200",
|
||||
},
|
||||
};
|
||||
|
||||
const statusColor = statusColors[crop.status as keyof typeof statusColors];
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className="w-full bg-muted/50 hover:bg-muted/80 transition-all cursor-pointer group hover:shadow-lg">
|
||||
className={`w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border-muted/60 bg-white dark:bg-slate-800 hover:bg-muted/10 dark:hover:bg-slate-700`}>
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sprout className="h-4 w-4 text-primary" />
|
||||
<Badge variant="outline" className={`capitalize ${statusColor.bg} ${statusColor.text} ${statusColor.border}`}>
|
||||
{crop.status}
|
||||
</Badge>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
{crop.plantedDate.toLocaleDateString()}
|
||||
</div>
|
||||
<span className={`text-sm font-medium capitalize ${statusColors[crop.status]}`}>{crop.status}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-medium truncate">{crop.name}</h3>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<p>Planted: {crop.plantedDate.toLocaleDateString()}</p>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`h-10 w-10 rounded-full ${statusColor.bg} flex-shrink-0 flex items-center justify-center`}>
|
||||
<Sprout className={`h-5 w-5 ${statusColor.text}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-medium mb-1">{crop.name}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{crop.variety} • {crop.area}
|
||||
</p>
|
||||
|
||||
{crop.status !== "planned" && (
|
||||
<div className="space-y-2 mt-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-medium">{crop.progress}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={crop.progress}
|
||||
className={`h-2 ${
|
||||
crop.status === "growing" ? "bg-green-500" : crop.status === "harvested" ? "bg-yellow-500" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crop.status === "growing" && (
|
||||
<div className="flex items-center mt-3 text-sm">
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-300">
|
||||
<BarChart className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Health: {crop.healthScore}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto gap-1 text-green-600 dark:text-green-300 hover:text-green-700 dark:hover:text-green-400 hover:bg-green-50/50 dark:hover:bg-green-800">
|
||||
View details <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -69,15 +69,15 @@ export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) {
|
||||
<DialogContent className="sm:max-w-[900px] p-0">
|
||||
<div className="grid md:grid-cols-2 h-[600px]">
|
||||
{/* Left side - Plant Selection */}
|
||||
<div className="p-6 overflow-y-auto border-r">
|
||||
<div className="p-6 overflow-y-auto border-r dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold mb-4">Select Plant to Grow</h2>
|
||||
<div className="space-y-4">
|
||||
{plants.map((plant) => (
|
||||
<Card
|
||||
key={plant.id}
|
||||
className={cn(
|
||||
"p-4 cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
selectedPlant === plant.id && "border-primary bg-primary/5"
|
||||
"p-4 cursor-pointer hover:bg-muted/50 dark:hover:bg-muted/40 transition-colors",
|
||||
selectedPlant === plant.id && "border-primary dark:border-primary dark:bg-primary/5 bg-primary/5"
|
||||
)}
|
||||
onClick={() => setSelectedPlant(plant.id)}>
|
||||
<div className="flex items-center gap-4">
|
||||
@ -101,25 +101,15 @@ export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) {
|
||||
|
||||
{/* Right side - Map */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-muted/10">
|
||||
<div className="h-full w-full bg-muted/20 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-muted/10 dark:bg-muted/20">
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<GoogleMapWithDrawing />
|
||||
{/* <div className="text-center space-y-2">
|
||||
<MapPin className="h-8 w-8 mx-auto text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Map placeholder
|
||||
<br />
|
||||
Lat: {location.lat.toFixed(4)}
|
||||
<br />
|
||||
Lng: {location.lng.toFixed(4)}
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t">
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-background dark:bg-background border-t dark:border-slate-700">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
|
||||
@ -1,158 +1,459 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, MapPin, Plus, Sprout } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
ArrowLeft,
|
||||
MapPin,
|
||||
Plus,
|
||||
Sprout,
|
||||
Calendar,
|
||||
LayoutGrid,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Home,
|
||||
ChevronRight,
|
||||
Droplets,
|
||||
Sun,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CropDialog } from "./crop-dialog";
|
||||
import { CropCard } from "./crop-card";
|
||||
import { Farm, Crop } from "@/types";
|
||||
import React from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { Farm, Crop } from "@/types";
|
||||
import { fetchFarmDetails } from "@/api/farm";
|
||||
|
||||
const crops: Crop[] = [
|
||||
{
|
||||
id: "1",
|
||||
farmId: "1",
|
||||
name: "Monthong Durian",
|
||||
plantedDate: new Date("2023-03-15"),
|
||||
status: "growing",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
farmId: "1",
|
||||
name: "Chanee Durian",
|
||||
plantedDate: new Date("2023-02-20"),
|
||||
status: "planned",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
farmId: "2",
|
||||
name: "Kradum Durian",
|
||||
plantedDate: new Date("2022-11-05"),
|
||||
status: "harvested",
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Used in Next.js; params is now a Promise and must be unwrapped with React.use()
|
||||
*/
|
||||
interface FarmDetailPageProps {
|
||||
params: Promise<{ farmId: string }>;
|
||||
}
|
||||
|
||||
const farms: Farm[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Green Valley Farm",
|
||||
location: "Bangkok",
|
||||
type: "durian",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Golden Farm",
|
||||
location: "Chiang Mai",
|
||||
type: "mango",
|
||||
createdAt: new Date("2022-12-01"),
|
||||
},
|
||||
];
|
||||
|
||||
const getFarmById = (id: string): Farm | undefined => {
|
||||
return farms.find((farm) => farm.id === id);
|
||||
};
|
||||
|
||||
const getCropsByFarmId = (farmId: string): Crop[] => crops.filter((crop) => crop.farmId === farmId);
|
||||
|
||||
export default function FarmDetailPage({ params }: { params: Promise<{ farmId: string }> }) {
|
||||
const { farmId } = React.use(params);
|
||||
export default function FarmDetailPage({ params }: FarmDetailPageProps) {
|
||||
// Unwrap the promised params using React.use() (experimental)
|
||||
const resolvedParams = React.use(params);
|
||||
|
||||
const router = useRouter();
|
||||
const [farm] = useState<Farm | undefined>(getFarmById(farmId));
|
||||
const [crops, setCrops] = useState<Crop[]>(getCropsByFarmId(farmId));
|
||||
const [farm, setFarm] = useState<Farm | null>(null);
|
||||
const [crops, setCrops] = useState<Crop[]>([]);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<string>("all");
|
||||
|
||||
// Fetch farm details on initial render using the resolved params
|
||||
useEffect(() => {
|
||||
async function loadFarmDetails() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const { farm, crops } = await fetchFarmDetails(resolvedParams.farmId);
|
||||
setFarm(farm);
|
||||
setCrops(crops);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message === "FARM_NOT_FOUND") {
|
||||
router.push("/not-found");
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("An unknown error occurred");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadFarmDetails();
|
||||
}, [resolvedParams.farmId, router]);
|
||||
|
||||
/**
|
||||
* Handles adding a new crop.
|
||||
*/
|
||||
const handleAddCrop = async (data: Partial<Crop>) => {
|
||||
try {
|
||||
// Simulate API delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
const newCrop: Crop = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
farmId: farm!.id,
|
||||
name: data.name!,
|
||||
plantedDate: data.plantedDate!,
|
||||
status: data.status!,
|
||||
variety: data.variety || "Standard",
|
||||
area: data.area || "0 hectares",
|
||||
healthScore: data.status === "growing" ? 85 : 0,
|
||||
progress: data.status === "growing" ? 10 : 0,
|
||||
};
|
||||
setCrops((prevCrops) => [...prevCrops, newCrop]);
|
||||
// When the crop gets added, close the dialog
|
||||
|
||||
setCrops((prev) => [newCrop, ...prev]);
|
||||
|
||||
// Update the farm's crop count
|
||||
if (farm) {
|
||||
setFarm({ ...farm, crops: farm.crops + 1 });
|
||||
}
|
||||
|
||||
setIsDialogOpen(false);
|
||||
} catch (err) {
|
||||
setError("Failed to add crop. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
// Filter crops based on the active filter
|
||||
const filteredCrops = crops.filter((crop) => activeFilter === "all" || crop.status === activeFilter);
|
||||
|
||||
// Calculate crop counts grouped by status
|
||||
const cropCounts = {
|
||||
all: crops.length,
|
||||
growing: crops.filter((crop) => crop.status === "growing").length,
|
||||
planned: crops.filter((crop) => crop.status === "planned").length,
|
||||
harvested: crops.filter((crop) => crop.status === "harvested").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-screen-xl p-8">
|
||||
<Button variant="ghost" className="mb-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Farms
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="container max-w-7xl p-6 mx-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Breadcrumbs */}
|
||||
<nav className="flex items-center text-sm text-muted-foreground">
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-normal text-muted-foreground"
|
||||
onClick={() => router.push("/")}>
|
||||
<Home className="h-3.5 w-3.5 mr-1" />
|
||||
Home
|
||||
</Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1" />
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-normal text-muted-foreground"
|
||||
onClick={() => router.push("/farms")}>
|
||||
Farms
|
||||
</Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1" />
|
||||
<span className="text-foreground font-medium truncate">{farm?.name || "Farm Details"}</span>
|
||||
</nav>
|
||||
|
||||
{/* Back button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit gap-2 text-muted-foreground"
|
||||
onClick={() => router.push("/farms")}>
|
||||
<ArrowLeft className="h-4 w-4" /> Back to Farms
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sprout className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">{farm?.name ?? "Unknown Farm"}</h1>
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Loading farm details...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Farm details */}
|
||||
{!isLoading && !error && farm && (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-12">
|
||||
{/* Farm info card */}
|
||||
<Card className="md:col-span-8">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200">
|
||||
{farm.type}
|
||||
</Badge>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<MapPin className="mr-1 h-4 w-4" />
|
||||
{farm?.location ?? "Unknown Location"}
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
Created {farm.createdAt.toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 mt-2">
|
||||
<div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-800 flex items-center justify-center">
|
||||
<Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{farm.name}</h1>
|
||||
<div className="flex items-center text-muted-foreground mt-1">
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
{farm.location}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">Farm Type:</span>
|
||||
<span className="text-muted-foreground">{farm?.type ?? "Unknown Type"}</span>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Area</p>
|
||||
<p className="text-lg font-semibold">{farm.area}</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">Created:</span>
|
||||
<span className="text-muted-foreground">{farm?.createdAt?.toLocaleDateString() ?? "Unknown Date"}</span>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Crops</p>
|
||||
<p className="text-lg font-semibold">{farm.crops}</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">Total Crops:</span>
|
||||
<span className="text-muted-foreground">{crops.length}</span>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Growing Crops</p>
|
||||
<p className="text-lg font-semibold">{cropCounts.growing}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Harvested</p>
|
||||
<p className="text-lg font-semibold">{cropCounts.harvested}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<h2 className="text-xl font-bold mb-4">Crops</h2>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{/* Clickable "Add Crop" Card */}
|
||||
<Card
|
||||
className="w-full bg-muted/50 hover:bg-muted/80 transition-all cursor-pointer group hover:shadow-lg"
|
||||
onClick={() => setIsDialogOpen(true)}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<Plus className="h-5 w-5 text-primary" />
|
||||
{/* Weather card */}
|
||||
<Card className="md:col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Current Conditions</CardTitle>
|
||||
<CardDescription>Weather at your farm location</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="p-2 rounded-lg bg-orange-50 dark:bg-orange-900">
|
||||
<Sun className="h-4 w-4 text-orange-500 dark:text-orange-200" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Temperature</p>
|
||||
<p className="text-xl font-semibold">{farm.weather?.temperature}°C</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900">
|
||||
<Droplets className="h-4 w-4 text-blue-500 dark:text-blue-200" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Humidity</p>
|
||||
<p className="text-xl font-semibold">{farm.weather?.humidity}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="p-2 rounded-lg bg-yellow-50 dark:bg-yellow-900">
|
||||
<Sun className="h-4 w-4 text-yellow-500 dark:text-yellow-200" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Sunlight</p>
|
||||
<p className="text-xl font-semibold">{farm.weather?.sunlight}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="p-2 rounded-lg bg-gray-50 dark:bg-gray-900">
|
||||
<Wind className="h-4 w-4 text-gray-500 dark:text-gray-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Rainfall</p>
|
||||
<p className="text-xl font-semibold">{farm.weather?.rainfall}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xl font-medium">Add Crop</h3>
|
||||
<p className="text-sm text-muted-foreground">Plant a new crop</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* New Crop Dialog */}
|
||||
<CropDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} onSubmit={handleAddCrop} />
|
||||
{/* Crops section */}
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center">
|
||||
<LayoutGrid className="h-5 w-5 mr-2 text-green-600 dark:text-green-300" />
|
||||
Crops
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage and monitor all crops in this farm</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add New Crop
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{crops.map((crop) => (
|
||||
<CropCard
|
||||
<Tabs defaultValue="all" className="mt-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all" onClick={() => setActiveFilter("all")}>
|
||||
All Crops ({cropCounts.all})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="growing" onClick={() => setActiveFilter("growing")}>
|
||||
Growing ({cropCounts.growing})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="planned" onClick={() => setActiveFilter("planned")}>
|
||||
Planned ({cropCounts.planned})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="harvested" onClick={() => setActiveFilter("harvested")}>
|
||||
Harvested ({cropCounts.harvested})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="mt-6">
|
||||
{filteredCrops.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
|
||||
<div className="bg-green-100 dark:bg-green-900 p-3 rounded-full mb-4">
|
||||
<Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">No crops found</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
{activeFilter === "all"
|
||||
? "You haven't added any crops to this farm yet."
|
||||
: `No ${activeFilter} crops found. Try a different filter.`}
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add your first crop
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredCrops.map((crop, index) => (
|
||||
<motion.div
|
||||
key={crop.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}>
|
||||
<CropCard
|
||||
crop={crop}
|
||||
onClick={() => {
|
||||
router.push(`/farms/${crop.farmId}/crops/${crop.id}`);
|
||||
}}
|
||||
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Growing tab */}
|
||||
<TabsContent value="growing" className="mt-6">
|
||||
{filteredCrops.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
|
||||
<div className="bg-green-100 dark:bg-green-900 p-3 rounded-full mb-4">
|
||||
<Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">No growing crops</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
You don't have any growing crops in this farm yet.
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add a growing crop
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredCrops.map((crop, index) => (
|
||||
<motion.div
|
||||
key={crop.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}>
|
||||
<CropCard
|
||||
crop={crop}
|
||||
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Planned tab */}
|
||||
<TabsContent value="planned" className="mt-6">
|
||||
{filteredCrops.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
|
||||
<h3 className="text-xl font-medium mb-2">No planned crops</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
You don't have any planned crops in this farm yet.
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Plan a new crop
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredCrops.map((crop, index) => (
|
||||
<motion.div
|
||||
key={crop.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}>
|
||||
<CropCard
|
||||
crop={crop}
|
||||
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Harvested tab */}
|
||||
<TabsContent value="harvested" className="mt-6">
|
||||
{filteredCrops.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
|
||||
<h3 className="text-xl font-medium mb-2">No harvested crops</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
You don't have any harvested crops in this farm yet.
|
||||
</p>
|
||||
<Button onClick={() => setActiveFilter("all")} className="gap-2">
|
||||
View all crops
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredCrops.map((crop, index) => (
|
||||
<motion.div
|
||||
key={crop.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}>
|
||||
<CropCard
|
||||
crop={crop}
|
||||
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Crop Dialog */}
|
||||
<CropDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} onSubmit={handleAddCrop} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,8 +7,16 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Farm } from "@/types";
|
||||
import { farmFormSchema } from "@/schemas/form.schema";
|
||||
|
||||
const farmFormSchema = z.object({
|
||||
name: z.string().min(2, "Farm name must be at least 2 characters"),
|
||||
location: z.string().min(2, "Location must be at least 2 characters"),
|
||||
type: z.string().min(1, "Please select a farm type"),
|
||||
area: z.string().optional(),
|
||||
});
|
||||
|
||||
interface AddFarmFormProps {
|
||||
onSubmit: (data: Partial<Farm>) => Promise<void>;
|
||||
@ -16,18 +24,33 @@ interface AddFarmFormProps {
|
||||
}
|
||||
|
||||
export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof farmFormSchema>>({
|
||||
resolver: zodResolver(farmFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
location: "",
|
||||
type: "",
|
||||
area: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof farmFormSchema>) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await onSubmit(values);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
console.error("Error submitting form:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@ -37,7 +60,7 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
<FormControl>
|
||||
<Input placeholder="Enter farm name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>This is your farm's display name.</FormDescription>
|
||||
<FormDescription>This is your farm's display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@ -52,6 +75,7 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
<FormControl>
|
||||
<Input placeholder="Enter farm location" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>City, region or specific address</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@ -73,6 +97,7 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
<SelectItem value="durian">Durian</SelectItem>
|
||||
<SelectItem value="mango">Mango</SelectItem>
|
||||
<SelectItem value="rice">Rice</SelectItem>
|
||||
<SelectItem value="mixed">Mixed Crops</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@ -81,11 +106,35 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="area"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Total Area (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., 10 hectares" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>The total size of your farm</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create Farm</Button>
|
||||
<Button type="submit" disabled={isSubmitting} className="bg-green-600 hover:bg-green-700">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Farm"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { MapPin, Sprout, Plus } from "lucide-react";
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card";
|
||||
import { MapPin, Sprout, Plus, CalendarDays, ArrowRight } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Farm } from "@/types";
|
||||
|
||||
export interface FarmCardProps {
|
||||
@ -9,50 +14,81 @@ export interface FarmCardProps {
|
||||
}
|
||||
|
||||
export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
|
||||
const cardClasses =
|
||||
"w-full max-w-[240px] bg-muted/50 hover:bg-muted/80 transition-all cursor-pointer group hover:shadow-lg";
|
||||
const cardClasses = cn(
|
||||
"w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border",
|
||||
variant === "add"
|
||||
? "bg-green-50/50 dark:bg-green-900/50 hover:bg-green-50/80 dark:hover:bg-green-900/80 border-dashed border-muted/60"
|
||||
: "bg-white dark:bg-slate-800 hover:bg-muted/10 dark:hover:bg-slate-700 border-muted/60"
|
||||
);
|
||||
|
||||
if (variant === "add") {
|
||||
return (
|
||||
<Card className={cardClasses} onClick={onClick}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<Plus className="h-5 w-5 text-primary" />
|
||||
<div className="flex flex-col items-center justify-center h-full p-6 text-center cursor-pointer">
|
||||
<div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-800 flex items-center justify-center mb-4 group-hover:bg-green-200 dark:group-hover:bg-green-700 transition-colors">
|
||||
<Plus className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xl font-medium">Setup</h3>
|
||||
<p className="text-sm text-muted-foreground">Setup new farm</p>
|
||||
<h3 className="text-xl font-medium mb-2">Add New Farm</h3>
|
||||
<p className="text-sm text-muted-foreground">Create a new farm to manage your crops and resources</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "farm" && farm) {
|
||||
const formattedDate = new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(farm.createdAt);
|
||||
|
||||
return (
|
||||
<Card className={cardClasses} onClick={onClick}>
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sprout className="h-4 w-4 text-primary" />
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200">
|
||||
{farm.type}
|
||||
</Badge>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3 w-3 mr-1" />
|
||||
{formattedDate}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-primary">{farm.type}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center">
|
||||
<Sprout className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium truncate">{farm.name}</h3>
|
||||
<div className="flex items-center gap-1 mt-1 text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<p className="text-sm">{farm.location}</p>
|
||||
<h3 className="text-xl font-medium mb-1">{farm.name}</h3>
|
||||
<div className="flex items-center text-sm text-muted-foreground mb-2">
|
||||
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{farm.location}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">Area</p>
|
||||
<p className="font-medium">{farm.area}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">Crops</p>
|
||||
<p className="font-medium">{farm.crops}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Created {farm.createdAt.toLocaleDateString()}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto gap-1 text-green-600 hover:text-green-700 hover:bg-green-50/50 dark:hover:bg-green-800">
|
||||
View details <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,54 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Search, Plus, Filter, SlidersHorizontal, Leaf, Calendar, AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Link, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
import { FarmCard } from "./farm-card";
|
||||
import { AddFarmForm } from "./add-farm-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Farm } from "@/types";
|
||||
import { fetchFarms } from "@/api/farm";
|
||||
|
||||
/**
|
||||
* FarmSetupPage component allows users to search, filter, sort, and add farms.
|
||||
*/
|
||||
export default function FarmSetupPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Component state
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [farms, setFarms] = useState<Farm[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "Green Valley Farm",
|
||||
location: "Bangkok",
|
||||
type: "Durian",
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
const [farms, setFarms] = useState<Farm[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<string>("all");
|
||||
const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest");
|
||||
|
||||
// Load farms when the component mounts.
|
||||
useEffect(() => {
|
||||
async function loadFarms() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchFarms();
|
||||
setFarms(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An unknown error occurred");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadFarms();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles adding a new farm.
|
||||
*
|
||||
* @param data - Partial Farm data from the form.
|
||||
*/
|
||||
const handleAddFarm = async (data: Partial<Farm>) => {
|
||||
try {
|
||||
// Simulate an API call delay for adding a new farm.
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
const newFarm: Farm = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: data.name!,
|
||||
location: data.location!,
|
||||
type: data.type!,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
setFarms([...farms, newFarm]);
|
||||
setIsDialogOpen(false);
|
||||
area: data.area || "0 hectares",
|
||||
crops: 0,
|
||||
};
|
||||
|
||||
const filteredFarms = farms.filter(
|
||||
setFarms((prev) => [newFarm, ...prev]);
|
||||
setIsDialogOpen(false);
|
||||
} catch (err) {
|
||||
setError("Failed to add farm. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and sort farms based on the current state.
|
||||
const filteredAndSortedFarms = farms
|
||||
.filter(
|
||||
(farm) =>
|
||||
farm.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(activeFilter === "all" || farm.type === activeFilter) &&
|
||||
(farm.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
farm.location.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
farm.type.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
farm.type.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (sortOrder === "newest") {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
} else if (sortOrder === "oldest") {
|
||||
return a.createdAt.getTime() - b.createdAt.getTime();
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Get available farm types for filters.
|
||||
const farmTypes = ["all", ...new Set(farms.map((farm) => farm.type))];
|
||||
|
||||
return (
|
||||
<div className="container max-w-screen-xl p-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">Farms</h1>
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<div className="min-h-screen bg-gradient-to-b">
|
||||
<div className="container max-w-7xl p-6 mx-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Your Farms</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage and monitor all your agricultural properties</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 md:w-64">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search farms..."
|
||||
className="pl-8"
|
||||
@ -56,32 +127,180 @@ export default function FarmSetupPage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2 bg-green-600 hover:bg-green-700">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Farm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
{/* Filtering and sorting controls */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{farmTypes.map((type) => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant={activeFilter === type ? "default" : "outline"}
|
||||
className={`capitalize cursor-pointer ${
|
||||
activeFilter === type ? "bg-green-600" : "hover:bg-green-100"
|
||||
}`}
|
||||
onClick={() => setActiveFilter(type)}>
|
||||
{type === "all" ? "All Farms" : type}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Sort
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className={sortOrder === "newest" ? "bg-green-50" : ""}
|
||||
onClick={() => setSortOrder("newest")}>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Newest first
|
||||
{sortOrder === "newest" && <Check className="h-4 w-4 ml-2" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={sortOrder === "oldest" ? "bg-green-50" : ""}
|
||||
onClick={() => setSortOrder("oldest")}>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Oldest first
|
||||
{sortOrder === "oldest" && <Check className="h-4 w-4 ml-2" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={sortOrder === "alphabetical" ? "bg-green-50" : ""}
|
||||
onClick={() => setSortOrder("alphabetical")}>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Alphabetical
|
||||
{sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-2" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Loading your farms...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !error && filteredAndSortedFarms.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 rounded-lg border border-dashed">
|
||||
<div className="bg-green-100 p-3 rounded-full mb-4">
|
||||
<Leaf className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">No farms found</h3>
|
||||
{searchQuery || activeFilter !== "all" ? (
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
No farms match your current filters. Try adjusting your search or filters.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
You haven't added any farms yet. Get started by adding your first farm.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setActiveFilter("all");
|
||||
if (!farms.length) {
|
||||
setIsDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
className="gap-2">
|
||||
{searchQuery || activeFilter !== "all" ? (
|
||||
"Clear filters"
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add your first farm
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid of farm cards */}
|
||||
{!isLoading && !error && filteredAndSortedFarms.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="col-span-1">
|
||||
<FarmCard variant="add" onClick={() => setIsDialogOpen(true)} />
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
</motion.div>
|
||||
|
||||
{filteredAndSortedFarms.map((farm, index) => (
|
||||
<motion.div
|
||||
key={farm.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="col-span-1">
|
||||
<FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.id}`)} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Farm Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Setup New Farm</DialogTitle>
|
||||
<DialogDescription>Fill out the form to configure your new farm.</DialogDescription>
|
||||
<DialogTitle>Add New Farm</DialogTitle>
|
||||
<DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{filteredFarms.map((farm) => (
|
||||
<FarmCard
|
||||
key={farm.id}
|
||||
variant="farm"
|
||||
farm={farm}
|
||||
onClick={() => {
|
||||
router.push(`/farms/${farm.id}`);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper component to render the Check icon.
|
||||
*
|
||||
* @param props - Optional className for custom styling.
|
||||
*/
|
||||
function Check({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user