ForFarm/frontend/app/(sidebar)/farms/page.tsx

276 lines
10 KiB
TypeScript

"use client";
import { useState } 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Input } from "@/components/ui/input";
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 type { Farm } from "@/types";
import { fetchFarms, createFarm } from "@/api/farm";
export default function FarmSetupPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<string>("all");
const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const {
data: farms,
isLoading,
isError,
error,
} = useQuery<Farm[]>({
queryKey: ["farms"],
queryFn: fetchFarms,
staleTime: 60 * 1000,
});
const mutation = useMutation({
mutationFn: (data: Partial<Farm>) => createFarm(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["farms"] });
setIsDialogOpen(false);
},
});
const filteredAndSortedFarms = (farms || [])
.filter(
(farm) =>
(activeFilter === "all" || farm.type === activeFilter) &&
(farm.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
farm.location.toLowerCase().includes(searchQuery.toLowerCase()) ||
farm.type.toLowerCase().includes(searchQuery.toLowerCase()))
)
.sort((a, b) => {
if (sortOrder === "newest") {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
} else if (sortOrder === "oldest") {
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
} else {
return a.name.localeCompare(b.name);
}
});
// Get distinct farm types.
const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.type))];
const handleAddFarm = async (data: Partial<Farm>) => {
await mutation.mutateAsync(data);
};
return (
<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"
value={searchQuery}
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>
{/* 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 */}
{isError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{(error as Error)?.message}</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 && !isError && 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 || farms.length === 0) {
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 && !isError && 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)} />
</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>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>
</div>
);
}
/**
* A helper component for the Check icon.
*/
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>
);
}