refactor: use camelCase instead of Pascal

This commit is contained in:
Sosokker 2025-04-03 10:33:13 +07:00
parent 9691b845d9
commit 1940a0034a
13 changed files with 1318 additions and 972 deletions

View File

@ -7,62 +7,131 @@ 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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Crop } from "@/types"; import { useQuery } from "@tanstack/react-query";
import { cropFormSchema } from "@/schemas/form.schema"; import { getPlants, PlantResponse } from "@/api/plant";
import { Loader2 } from "lucide-react";
import { Cropland } from "@/types";
// Update schema to reflect Cropland fields needed for creation
// Removed plantedDate as it's derived from createdAt on backend
// Added plantId, landSize, growthStage, priority
const cropFormSchema = z.object({
name: z.string().min(2, "Crop name must be at least 2 characters"),
plantId: z.string().uuid("Please select a valid plant"), // Changed from name to ID
status: z.enum(["planned", "growing", "harvested", "fallow"]), // Added fallow
landSize: z.preprocess(
(val) => parseFloat(z.string().parse(val)), // Convert string input to number
z.number().positive("Land size must be a positive number")
),
growthStage: z.string().min(1, "Growth stage is required"),
priority: z.preprocess(
(val) => parseInt(z.string().parse(val), 10), // Convert string input to number
z.number().int().min(0, "Priority must be non-negative")
),
// GeoFeature will be handled separately by the map component later
});
interface AddCropFormProps { interface AddCropFormProps {
onSubmit: (data: Partial<Crop>) => Promise<void>; onSubmit: (data: Partial<Cropland>) => Promise<void>; // Expect Partial<Cropland>
onCancel: () => void; onCancel: () => void;
isSubmitting: boolean; // Receive submitting state
} }
export function AddCropForm({ onSubmit, onCancel }: AddCropFormProps) { export function AddCropForm({ onSubmit, onCancel, isSubmitting }: AddCropFormProps) {
// Fetch plants for the dropdown
const {
data: plantData,
isLoading: isLoadingPlants,
isError: isErrorPlants,
} = useQuery<PlantResponse>({
queryKey: ["plants"],
queryFn: getPlants,
staleTime: 1000 * 60 * 60, // Cache for 1 hour
});
const form = useForm<z.infer<typeof cropFormSchema>>({ const form = useForm<z.infer<typeof cropFormSchema>>({
resolver: zodResolver(cropFormSchema), resolver: zodResolver(cropFormSchema),
defaultValues: { defaultValues: {
name: "", name: "",
plantedDate: "", plantId: "", // Initialize plantId
status: "planned", status: "planned",
landSize: 0,
growthStage: "Planned",
priority: 1,
}, },
}); });
const handleSubmit = (values: z.infer<typeof cropFormSchema>) => { const handleSubmit = (values: z.infer<typeof cropFormSchema>) => {
// Submit data shaped like Partial<Cropland>
onSubmit({ onSubmit({
...values, name: values.name,
plantedDate: new Date(values.plantedDate), plantId: values.plantId,
status: values.status,
landSize: values.landSize,
growthStage: values.growthStage,
priority: values.priority,
// farmId is added in the parent component's mutationFn
// geoFeature would be passed separately if using map here
}); });
}; };
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Crop Name</FormLabel> <FormLabel>Cropland Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Enter crop name" {...field} /> <Input placeholder="e.g., North Field Tomatoes" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Plant Selection */}
<FormField <FormField
control={form.control} control={form.control}
name="plantedDate" name="plantId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Planted Date</FormLabel> <FormLabel>Select Plant</FormLabel>
<FormControl> <Select
<Input type="date" {...field} /> onValueChange={field.onChange}
</FormControl> defaultValue={field.value}
disabled={isLoadingPlants || isErrorPlants}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingPlants
? "Loading plants..."
: isErrorPlants
? "Error loading plants"
: "Select the main plant for this cropland"
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{!isLoadingPlants &&
!isErrorPlants &&
plantData?.plants.map((plant) => (
<SelectItem key={plant.uuid} value={plant.uuid}>
{plant.name} {plant.variety ? `(${plant.variety})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Status Selection */}
<FormField <FormField
control={form.control} control={form.control}
name="status" name="status"
@ -79,6 +148,7 @@ export function AddCropForm({ onSubmit, onCancel }: AddCropFormProps) {
<SelectItem value="planned">Planned</SelectItem> <SelectItem value="planned">Planned</SelectItem>
<SelectItem value="growing">Growing</SelectItem> <SelectItem value="growing">Growing</SelectItem>
<SelectItem value="harvested">Harvested</SelectItem> <SelectItem value="harvested">Harvested</SelectItem>
<SelectItem value="fallow">Fallow</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@ -86,11 +156,73 @@ export function AddCropForm({ onSubmit, onCancel }: AddCropFormProps) {
)} )}
/> />
<div className="flex justify-end gap-2"> {/* Land Size */}
<Button type="button" variant="outline" onClick={onCancel}> <FormField
control={form.control}
name="landSize"
render={({ field }) => (
<FormItem>
<FormLabel>Land Size (e.g., Hectares)</FormLabel>
<FormControl>
{/* Use text input for flexibility, validation handles number conversion */}
<Input
type="text"
placeholder="e.g., 1.5"
{...field}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Growth Stage */}
<FormField
control={form.control}
name="growthStage"
render={({ field }) => (
<FormItem>
<FormLabel>Initial Growth Stage</FormLabel>
<FormControl>
<Input placeholder="e.g., Seedling, Vegetative" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Priority */}
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
type="number"
placeholder="e.g., 1"
{...field}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* TODO: Add GeoFeature input using the map component if needed within this dialog */}
{/* <div className="h-64 border rounded-md overflow-hidden"> <GoogleMapWithDrawing onShapeDrawn={...} /> </div> */}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
Cancel Cancel
</Button> </Button>
<Button type="submit">Add Crop</Button> <Button type="submit" disabled={isSubmitting || isLoadingPlants}>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{isSubmitting ? "Adding Crop..." : "Add Crop"}
</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@ -1,19 +1,30 @@
"use client"; "use client";
import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card";
import { Sprout, Calendar, ArrowRight, BarChart } from "lucide-react"; import { Calendar, ArrowRight, Layers } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import type { Cropland } from "@/types";
import type { Crop } from "@/types";
// ===================================================================
// Component Props: CropCard expects a cropland object and an optional click handler.
// ===================================================================
interface CropCardProps { interface CropCardProps {
crop: Crop; crop: Cropland; // Crop data conforming to the Cropland type
onClick?: () => void; onClick?: () => void;
} }
// ===================================================================
// Component: CropCard
// - Displays summary information about a crop, including status,
// created date, and growth stage using an expressive card UI.
// ===================================================================
export function CropCard({ crop, onClick }: CropCardProps) { export function CropCard({ crop, onClick }: CropCardProps) {
const statusColors = { // ---------------------------------------------------------------
// Status color mapping: Determines badge styling based on crop status.
// Default colors provided for unknown statuses.
// ---------------------------------------------------------------
const statusColors: Record<string, { bg: string; text: string; border: string }> = {
growing: { growing: {
bg: "bg-green-50 dark:bg-green-900", bg: "bg-green-50 dark:bg-green-900",
text: "text-green-600 dark:text-green-300", text: "text-green-600 dark:text-green-300",
@ -29,67 +40,73 @@ export function CropCard({ crop, onClick }: CropCardProps) {
text: "text-blue-600 dark:text-blue-300", text: "text-blue-600 dark:text-blue-300",
border: "border-blue-200", border: "border-blue-200",
}, },
fallow: {
bg: "bg-gray-50 dark:bg-gray-900",
text: "text-gray-600 dark:text-gray-400",
border: "border-gray-200",
},
default: {
bg: "bg-gray-100 dark:bg-gray-700",
text: "text-gray-800 dark:text-gray-200",
border: "border-gray-300",
},
}; };
const statusColor = statusColors[crop.status as keyof typeof statusColors]; // ---------------------------------------------------------------
// Derive styling based on crop status (ignoring case).
// ---------------------------------------------------------------
const statusKey = crop.status?.toLowerCase() || "default"; // Use camelCase status
const statusColor = statusColors[statusKey] || statusColors.default;
// ---------------------------------------------------------------
// Format metadata for display: creation date and area.
// ---------------------------------------------------------------
const displayDate = crop.createdAt ? new Date(crop.createdAt).toLocaleDateString() : "N/A"; // Use camelCase createdAt
const displayArea = typeof crop.landSize === "number" ? `${crop.landSize} ha` : "N/A"; // Use camelCase landSize
// ===================================================================
// Render: Crop information card with clickable behavior.
// ===================================================================
return ( return (
<Card <Card
onClick={onClick} onClick={onClick}
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`}> className={`w-full h-full flex flex-col overflow-hidden transition-all duration-200 hover:shadow-lg border-muted/60 bg-card dark:bg-card hover:bg-muted/10 dark:hover:bg-slate-700 cursor-pointer`}>
<CardHeader className="p-4 pb-0"> <CardHeader className="p-4 pb-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Badge variant="outline" className={`capitalize ${statusColor.bg} ${statusColor.text} ${statusColor.border}`}> <Badge variant="outline" className={`capitalize ${statusColor.bg} ${statusColor.text} ${statusColor.border}`}>
{crop.status} {crop.status || "Unknown"}
</Badge> </Badge>
<div className="flex items-center text-xs text-muted-foreground"> <div className="flex items-center text-xs text-muted-foreground">
<Calendar className="h-3 w-3 mr-1" /> <Calendar className="h-3 w-3 mr-1" />
{crop.plantedDate.toLocaleDateString()} {displayDate}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4 flex-grow">
<div className="flex items-start gap-3"> <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`}> {/* ... icon ... */}
<Sprout className={`h-5 w-5 ${statusColor.text}`} />
</div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-xl font-medium mb-1">{crop.name}</h3> <h3 className="text-lg font-semibold mb-1 line-clamp-1">{crop.name}</h3> {/* Use camelCase name */}
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
{crop.variety} {crop.area} {crop.growthStage || "N/A"} {displayArea} {/* Use camelCase growthStage */}
</p> </p>
{crop.growthStage && (
{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 mt-3 text-sm">
<div className="flex items-center gap-1 text-green-600 dark:text-green-300"> <div className="flex items-center gap-1 text-muted-foreground">
<BarChart className="h-3.5 w-3.5" /> <Layers className="h-3.5 w-3.5" />
<span className="font-medium">Health: {crop.healthScore}%</span> {/* Use camelCase growthStage */}
<span className="font-medium">Stage: {crop.growthStage}</span>
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</CardContent> </CardContent>
<CardFooter className="p-4 pt-0"> <CardFooter className="p-4 pt-0 mt-auto">
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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"> className="ml-auto gap-1 text-primary hover:text-primary/80 hover:bg-primary/10">
View details <ArrowRight className="h-3.5 w-3.5" /> View details <ArrowRight className="h-3.5 w-3.5" />
</Button> </Button>
</CardFooter> </CardFooter>

View File

@ -1,125 +1,301 @@
// crop-dialog.tsx
"use client"; "use client";
import { useState } from "react"; import React, { useState, useMemo, useCallback, useEffect } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { useQuery } from "@tanstack/react-query";
import { useMapsLibrary } from "@vis.gl/react-google-maps";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Check, MapPin } from "lucide-react"; import {
Check,
Sprout,
AlertTriangle,
Loader2,
CalendarDays,
Thermometer,
Droplets,
MapPin,
Maximize,
} from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Crop } from "@/types"; // Import the updated/new types
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import type { Cropland, GeoFeatureData, GeoPosition } from "@/types";
import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; import { PlantResponse } from "@/api/plant";
import { getPlants } from "@/api/plant";
interface Plant { // Import the map component and the ShapeData type (ensure ShapeData in types.ts matches this)
id: string; import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing";
name: string;
image: string;
growthTime: string;
}
const plants: Plant[] = [
{
id: "durian",
name: "Durian",
image: "/placeholder.svg?height=80&width=80",
growthTime: "4-5 months",
},
{
id: "mango",
name: "Mango",
image: "/placeholder.svg?height=80&width=80",
growthTime: "3-4 months",
},
{
id: "coconut",
name: "Coconut",
image: "/placeholder.svg?height=80&width=80",
growthTime: "5-6 months",
},
];
interface CropDialogProps { interface CropDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (data: Partial<Crop>) => Promise<void>; onSubmit: (data: Partial<Cropland>) => Promise<void>;
isSubmitting: boolean;
} }
export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) { export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropDialogProps) {
const [selectedPlant, setSelectedPlant] = useState<string | null>(null); // --- State ---
const [location, setLocation] = useState({ lat: 13.7563, lng: 100.5018 }); // Bangkok coordinates const [selectedPlantUUID, setSelectedPlantUUID] = useState<string | null>(null);
// State to hold the structured GeoFeature data
const [geoFeature, setGeoFeature] = useState<GeoFeatureData | null>(null);
const [calculatedArea, setCalculatedArea] = useState<number | null>(null); // Keep for display
// --- Load Google Maps Geometry Library ---
const geometryLib = useMapsLibrary("geometry");
// --- Fetch Plants ---
const {
data: plantData,
isLoading: isLoadingPlants,
isError: isErrorPlants,
error: errorPlants,
} = useQuery<PlantResponse>({
queryKey: ["plants"],
queryFn: getPlants,
staleTime: 1000 * 60 * 60,
refetchOnWindowFocus: false,
});
const plants = useMemo(() => plantData?.plants || [], [plantData]);
const selectedPlant = useMemo(() => {
return plants.find((p) => p.uuid === selectedPlantUUID);
}, [plants, selectedPlantUUID]);
// --- Reset State on Dialog Close ---
useEffect(() => {
if (!open) {
setSelectedPlantUUID(null);
setGeoFeature(null); // Reset geoFeature state
setCalculatedArea(null);
}
}, [open]);
// --- Map Interaction Handler ---
const handleShapeDrawn = useCallback(
(data: ShapeData) => {
console.log("Shape drawn:", data);
if (!geometryLib) {
console.warn("Geometry library not loaded yet.");
return;
}
let feature: GeoFeatureData | null = null;
let area: number | null = null;
// Helper to ensure path points are valid GeoPositions
const mapPath = (path?: { lat: number; lng: number }[]): GeoPosition[] =>
(path || []).map((p) => ({ lat: p.lat, lng: p.lng }));
// Helper to ensure position is a valid GeoPosition
const mapPosition = (pos?: { lat: number; lng: number }): GeoPosition | null =>
pos ? { lat: pos.lat, lng: pos.lng } : null;
if (data.type === "polygon" && data.path && data.path.length > 0) {
const geoPath = mapPath(data.path);
feature = { type: "polygon", path: geoPath };
// Use original path for calculation if library expects {lat, lng}
area = geometryLib.spherical.computeArea(data.path);
console.log("Polygon drawn, Area:", area, "m²");
} else if (data.type === "polyline" && data.path && data.path.length > 0) {
const geoPath = mapPath(data.path);
feature = { type: "polyline", path: geoPath };
area = null;
console.log("Polyline drawn, Path:", data.path);
} else if (data.type === "marker" && data.position) {
const geoPos = mapPosition(data.position);
if (geoPos) {
feature = { type: "marker", position: geoPos };
}
area = null;
console.log("Marker drawn at:", data.position);
} else {
console.log(`Ignoring shape type: ${data.type} or empty path/position`);
feature = null;
area = null;
}
setGeoFeature(feature);
setCalculatedArea(area);
},
[geometryLib] // Depend on geometryLib
);
// --- Submit Handler ---
const handleSubmit = async () => { const handleSubmit = async () => {
if (!selectedPlant) return; // Check for geoFeature instead of just drawnPath
if (!selectedPlantUUID || !geoFeature) {
alert("Please select a plant and define a feature (marker, polygon, or polyline) on the map.");
return;
}
// selectedPlant is derived from state using useMemo
if (!selectedPlant) {
alert("Selected plant not found."); // Should not happen if UUID is set
return;
}
await onSubmit({ const cropData: Partial<Cropland> = {
name: plants.find((p) => p.id === selectedPlant)?.name || "", // Default name, consider making this editable
plantedDate: new Date(), name: `${selectedPlant.name} Field ${Math.floor(100 + Math.random() * 900)}`,
status: "planned", plantId: selectedPlant.uuid,
}); status: "planned", // Default status
// Use calculatedArea if available (only for polygons), otherwise maybe 0
// The backend might ignore this if it calculates based on GeoFeature
landSize: calculatedArea ?? 0,
growthStage: "Planned", // Default growth stage
priority: 1, // Default priority
geoFeature: geoFeature, // Add the structured geoFeature data
// FarmID will be added in the page component mutationFn
};
setSelectedPlant(null); console.log("Submitting Cropland Data:", cropData);
onOpenChange(false);
try {
await onSubmit(cropData);
// State reset handled by useEffect watching 'open'
} catch (error) {
console.error("Submission failed in dialog:", error);
// Optionally show an error message to the user within the dialog
}
}; };
// --- Render ---
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<VisuallyHidden> <DialogContent className="sm:max-w-[950px] md:max-w-[1100px] lg:max-w-[1200px] xl:max-w-7xl p-0 max-h-[90vh] flex flex-col">
<DialogTitle></DialogTitle> <DialogHeader className="p-6 pb-0">
</VisuallyHidden> <DialogTitle className="text-xl font-semibold">Create New Cropland</DialogTitle>
<DialogContent className="sm:max-w-[900px] p-0"> <DialogDescription>
<div className="grid md:grid-cols-2 h-[600px]"> Select a plant and draw the cropland boundary or mark its location on the map.
{/* Left side - Plant Selection */} </DialogDescription>
<div className="p-6 overflow-y-auto border-r dark:border-slate-700"> </DialogHeader>
<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 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">
<img
src={plant.image || "/placeholder.svg"}
alt={plant.name}
className="w-20 h-20 rounded-lg object-cover"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium">{plant.name}</h3>
{selectedPlant === plant.id && <Check className="h-4 w-4 text-primary" />}
</div>
<p className="text-sm text-muted-foreground">Growth time: {plant.growthTime}</p>
</div>
</div>
</Card>
))}
</div>
</div>
{/* Right side - Map */} <div className="flex-grow grid md:grid-cols-12 gap-0 overflow-hidden">
<div className="relative"> {/* Left Side: Plant Selection */}
<div className="absolute inset-0 bg-muted/10 dark:bg-muted/20"> <div className="md:col-span-4 lg:col-span-3 p-6 pt-2 border-r dark:border-slate-700 overflow-y-auto">
<div className="h-full w-full flex items-center justify-center"> <h3 className="text-md font-medium mb-4 sticky top-0 bg-background py-2">1. Select Plant</h3>
<GoogleMapWithDrawing /> {/* Plant selection UI */}
{isLoadingPlants && (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading plants...</span>
</div> </div>
</div> )}
{isErrorPlants && (
<div className="text-destructive flex items-center gap-2 bg-destructive/10 p-3 rounded-md">
<AlertTriangle className="h-5 w-5" />
<span>Error loading plants: {(errorPlants as Error)?.message}</span>
</div>
)}
{!isLoadingPlants && !isErrorPlants && plants.length === 0 && (
<div className="text-center py-10 text-muted-foreground">No plants available.</div>
)}
{!isLoadingPlants && !isErrorPlants && plants.length > 0 && (
<div className="space-y-3">
{plants.map((plant) => (
<Card
key={plant.uuid}
className={cn(
"p-3 cursor-pointer hover:bg-muted/50 dark:hover:bg-muted/40 transition-colors",
selectedPlantUUID === plant.uuid &&
"border-2 border-primary dark:border-primary dark:bg-primary/5 bg-primary/5"
)}
onClick={() => setSelectedPlantUUID(plant.uuid)}>
<CardContent className="p-0">
<div className="flex items-start gap-3">
<div className="w-16 h-16 rounded-md bg-gradient-to-br from-green-100 to-lime-100 dark:from-green-900 dark:to-lime-900 flex items-center justify-center">
<Sprout className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">
{plant.name} <span className="text-xs text-muted-foreground">({plant.variety})</span>
</h4>
{selectedPlantUUID === plant.uuid && (
<Check className="h-4 w-4 text-primary flex-shrink-0" />
)}
</div>
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
<p className="flex items-center">
<CalendarDays className="h-3 w-3 mr-1" /> Maturity: ~{plant.daysToMaturity ?? "N/A"} days
</p>
<p className="flex items-center">
<Thermometer className="h-3 w-3 mr-1" /> Temp: {plant.optimalTemp ?? "N/A"}°C
</p>
<p className="flex items-center">
<Droplets className="h-3 w-3 mr-1" /> Water: {plant.waterNeeds ?? "N/A"}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div> </div>
{/* Footer */} {/* Right Side: Map */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-background dark:bg-background border-t dark:border-slate-700"> <div className="md:col-span-8 lg:col-span-9 p-6 pt-2 flex flex-col overflow-hidden">
<div className="flex justify-end gap-2"> <h3 className="text-md font-medium mb-4">2. Define Boundary / Location</h3>
<Button variant="outline" onClick={() => onOpenChange(false)}> <div className="flex-grow bg-muted/30 dark:bg-muted/20 rounded-md border dark:border-slate-700 overflow-hidden relative">
Cancel <GoogleMapWithDrawing onShapeDrawn={handleShapeDrawn} />
</Button>
<Button onClick={handleSubmit} disabled={!selectedPlant}> {/* Display feedback based on drawn shape */}
Plant Crop {geoFeature?.type === "polygon" && calculatedArea !== null && (
</Button> <div className="absolute bottom-2 right-2 bg-background/80 backdrop-blur-sm p-2 rounded shadow-md text-sm flex items-center gap-1">
<Maximize className="h-3 w-3 text-blue-600" />
Area: {calculatedArea.toFixed(2)} m²
</div>
)}
{geoFeature?.type === "polyline" && geoFeature.path && (
<div className="absolute bottom-2 right-2 bg-background/80 backdrop-blur-sm p-2 rounded shadow-md text-sm flex items-center gap-1">
<MapPin className="h-3 w-3 text-orange-600" />
Boundary path defined ({geoFeature.path.length} points).
</div>
)}
{geoFeature?.type === "marker" && geoFeature.position && (
<div className="absolute bottom-2 right-2 bg-background/80 backdrop-blur-sm p-2 rounded shadow-md text-sm flex items-center gap-1">
<MapPin className="h-3 w-3 text-red-600" />
Marker set at {geoFeature.position.lat.toFixed(4)}, {geoFeature.position.lng.toFixed(4)}.
</div>
)}
{!geometryLib && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Loading map tools...
</div>
)}
</div> </div>
<p className="text-xs text-muted-foreground mt-2">
Use the drawing tools (Polygon <Maximize className="inline h-3 w-3" />, Polyline{" "}
<MapPin className="inline h-3 w-3" />, Marker <MapPin className="inline h-3 w-3 text-red-500" />) above
the map. Area is calculated for polygons.
</p>
</div> </div>
</div> </div>
{/* Dialog Footer */}
<DialogFooter className="p-6 pt-4 border-t dark:border-slate-700 mt-auto">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
{/* Disable submit if no plant OR no feature is selected */}
<Button onClick={handleSubmit} disabled={!selectedPlantUUID || !geoFeature || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create Cropland"
)}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -4,12 +4,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LineChart, Sprout, Droplets, Sun } from "lucide-react"; import { LineChart, Sprout, Droplets, Sun } from "lucide-react";
import type { Crop, CropAnalytics } from "@/types"; import type { CropAnalytics, Cropland } from "@/types";
interface AnalyticsDialogProps { interface AnalyticsDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
crop: Crop; crop: Cropland;
analytics: CropAnalytics; analytics: CropAnalytics;
} }

View File

@ -1,30 +1,29 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter, useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowLeft,
Sprout,
LineChart, LineChart,
MessageSquare,
Settings, Settings,
Droplets, Droplets,
Sun, Sun,
ThermometerSun, ThermometerSun,
Timer, Timer,
ListCollapse, ListCollapse,
Calendar,
Leaf, Leaf,
CloudRain, CloudRain,
Wind, Wind,
Home,
ChevronRight,
AlertTriangle,
Loader2,
LeafIcon,
History,
Bot,
} from "lucide-react"; } from "lucide-react";
import { import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
@ -32,56 +31,121 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { ChatbotDialog } from "./chatbot-dialog"; import { ChatbotDialog } from "./chatbot-dialog";
import { AnalyticsDialog } from "./analytics-dialog"; import { AnalyticsDialog } from "./analytics-dialog";
import { import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
HoverCard, import type { Cropland, CropAnalytics, Farm } from "@/types";
HoverCardContent, import { getFarm } from "@/api/farm";
HoverCardTrigger, import { getPlants, PlantResponse } from "@/api/plant";
} from "@/components/ui/hover-card"; import { getCropById, fetchCropAnalytics } from "@/api/crop";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import type { Crop, CropAnalytics } from "@/types";
import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
import { fetchCropById, fetchAnalyticsByCropId } from "@/api/farm";
interface CropDetailPageParams { export default function CropDetailPage() {
farmId: string;
cropId: string;
}
export default function CropDetailPage({
params,
}: {
params: Promise<CropDetailPageParams>;
}) {
const router = useRouter(); const router = useRouter();
const [crop, setCrop] = useState<Crop | null>(null); const params = useParams<{ farmId: string; cropId: string }>();
const [analytics, setAnalytics] = useState<CropAnalytics | null>(null); const { farmId, cropId } = params;
const [isChatOpen, setIsChatOpen] = useState(false); const [isChatOpen, setIsChatOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
useEffect(() => { // --- Fetch Farm Data ---
async function fetchData() { const { data: farm, isLoading: isLoadingFarm } = useQuery<Farm>({
const resolvedParams = await params; queryKey: ["farm", farmId],
const cropData = await fetchCropById(resolvedParams.cropId); queryFn: () => getFarm(farmId),
const analyticsData = await fetchAnalyticsByCropId(resolvedParams.cropId); enabled: !!farmId,
setCrop(cropData); staleTime: 5 * 60 * 1000,
setAnalytics(analyticsData); });
}
fetchData();
}, [params]);
if (!crop || !analytics) { // --- Fetch Cropland Data ---
const {
data: cropland,
isLoading: isLoadingCropland,
isError: isErrorCropland,
error: errorCropland,
} = useQuery<Cropland>({
queryKey: ["crop", cropId],
queryFn: () => getCropById(cropId),
enabled: !!cropId,
staleTime: 60 * 1000,
});
// --- Fetch All Plants Data ---
const {
data: plantData,
isLoading: isLoadingPlants,
isError: isErrorPlants,
error: errorPlants,
} = useQuery<PlantResponse>({
queryKey: ["plants"],
queryFn: getPlants,
staleTime: 1000 * 60 * 60,
refetchOnWindowFocus: false,
});
// --- Derive specific Plant ---
const plant = useMemo(() => {
if (!cropland?.plantId || !plantData?.plants) return null;
return plantData.plants.find((p) => p.uuid === cropland.plantId);
}, [cropland, plantData]);
// --- Fetch Crop Analytics Data ---
const {
data: analytics, // Type is CropAnalytics | null
isLoading: isLoadingAnalytics,
isError: isErrorAnalytics,
error: errorAnalytics,
} = useQuery<CropAnalytics | null>({
queryKey: ["cropAnalytics", cropId],
queryFn: () => fetchCropAnalytics(cropId),
enabled: !!cropId,
staleTime: 5 * 60 * 1000,
});
// --- Combined Loading and Error States ---
const isLoading = isLoadingFarm || isLoadingCropland || isLoadingPlants || isLoadingAnalytics;
const isError = isErrorCropland || isErrorPlants || isErrorAnalytics; // Prioritize crop/analytics errors
const error = errorCropland || errorPlants || errorAnalytics;
// --- Loading State ---
if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background text-foreground"> <div className="min-h-screen flex items-center justify-center bg-background text-foreground">
Loading... <Loader2 className="h-8 w-8 animate-spin text-green-600" />
<span className="ml-2">Loading crop details...</span>
</div> </div>
); );
} }
// --- Error State ---
if (isError || !cropland) {
console.error("Error loading crop details:", error);
return (
<div className="min-h-screen container max-w-7xl p-6 mx-auto">
<Button
variant="outline"
size="sm"
className="w-fit gap-2 text-muted-foreground mb-6"
onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error Loading Crop Details</AlertTitle>
<AlertDescription>
{isErrorCropland
? `Crop with ID ${cropId} not found or could not be loaded.`
: (error as Error)?.message || "An unexpected error occurred."}
</AlertDescription>
</Alert>
</div>
);
}
// --- Data available, render page ---
const healthColors = { const healthColors = {
good: "text-green-500 bg-green-50 dark:bg-green-900", good: "text-green-500 bg-green-50 dark:bg-green-900 border-green-200",
warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900", warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900 border-yellow-200",
critical: "text-red-500 bg-red-50 dark:bg-red-900", critical: "text-red-500 bg-red-50 dark:bg-red-900 border-red-200",
}; };
const healthStatus = analytics?.plantHealth || "good";
const quickActions = [ const quickActions = [
{ {
@ -93,7 +157,7 @@ export default function CropDetailPage({
}, },
{ {
title: "Chat Assistant", title: "Chat Assistant",
icon: MessageSquare, icon: Bot,
description: "Get help and advice", description: "Get help and advice",
onClick: () => setIsChatOpen(true), onClick: () => setIsChatOpen(true),
color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300", color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300",
@ -102,96 +166,89 @@ export default function CropDetailPage({
title: "Crop Details", title: "Crop Details",
icon: ListCollapse, icon: ListCollapse,
description: "View detailed information", description: "View detailed information",
onClick: () => console.log("Details clicked"), onClick: () => console.log("Details clicked - Placeholder"),
color: color: "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300",
"bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300",
}, },
{ {
title: "Settings", title: "Settings",
icon: Settings, icon: Settings,
description: "Configure crop settings", description: "Configure crop settings",
onClick: () => console.log("Settings clicked"), onClick: () => console.log("Settings clicked - Placeholder"),
color: "bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-300", color: "bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-300",
}, },
]; ];
const plantedDate = cropland.createdAt ? new Date(cropland.createdAt) : null;
const daysToMaturity = plant?.daysToMaturity; // Use camelCase
const expectedHarvestDate =
plantedDate && daysToMaturity ? new Date(plantedDate.getTime() + daysToMaturity * 24 * 60 * 60 * 1000) : null;
const growthProgress = analytics?.growthProgress ?? 0; // Get from analytics
const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize} ha` : "N/A"; // Use camelCase
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<div className="container max-w-7xl p-6 mx-auto"> <div className="container max-w-7xl p-6 mx-auto">
{/* Breadcrumbs */}
<nav className="flex items-center text-sm text-muted-foreground mb-4">
<Button
variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary"
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 hover:text-primary"
onClick={() => router.push("/farms")}>
Farms
</Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" />
<Button
variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary max-w-[150px] truncate"
onClick={() => router.push(`/farms/${farmId}`)}>
{farm?.name || "Farm"} {/* Use camelCase */}
</Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" />
<span className="text-foreground font-medium truncate">{cropland.name || "Crop"}</span> {/* Use camelCase */}
</nav>
{/* Header */} {/* Header */}
<div className="flex flex-col gap-6 mb-8"> <div className="flex flex-col gap-6 mb-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
variant="ghost" variant="outline"
className="gap-2 text-green-700 dark:text-green-300 hover:text-green-800 dark:hover:text-green-200 hover:bg-green-100/50 dark:hover:bg-green-800/50" size="sm"
onClick={() => router.back()} className="w-fit gap-2 text-muted-foreground"
> onClick={() => router.push(`/farms/${farmId}`)}>
<ArrowLeft className="h-4 w-4" /> Back to Farm <ArrowLeft className="h-4 w-4" /> Back to Farm
</Button> </Button>
<HoverCard> {/* Hover Card (removed for simplicity, add back if needed) */}
<HoverCardTrigger asChild>
<Button variant="outline" className="gap-2">
<Calendar className="h-4 w-4" /> Timeline
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="flex justify-between space-x-4">
<Avatar>
<AvatarImage src="/placeholder.svg" />
<AvatarFallback>
<Sprout className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<h4 className="text-sm font-semibold">Growth Timeline</h4>
<p className="text-sm text-muted-foreground">
Planted on {crop.plantedDate.toLocaleDateString()}
</p>
<div className="flex items-center pt-2">
<Separator className="w-full" />
<span className="mx-2 text-xs text-muted-foreground">
{Math.floor(analytics.growthProgress)}% Complete
</span>
<Separator className="w-full" />
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
</div> </div>
<div className="flex flex-col md:flex-row justify-between gap-4"> <div className="flex flex-col md:flex-row justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">{crop.name}</h1> <h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1> {/* Use camelCase */}
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{crop.variety} {crop.area} {plant?.variety || "Unknown Variety"} {displayArea} {/* Use camelCase */}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge variant="outline" className={`${healthColors[healthStatus]} border capitalize`}>
variant="outline" {cropland.status} {/* Use camelCase */}
className={`${healthColors[analytics.plantHealth]} border`}
>
Health Score: {crop.healthScore}%
</Badge>
<Badge
variant="outline"
className="bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300"
>
Growing
</Badge> </Badge>
</div> </div>
{crop.expectedHarvest ? ( {expectedHarvestDate ? (
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Expected harvest:{" "} Expected harvest: {expectedHarvestDate.toLocaleDateString()}
{crop.expectedHarvest.toLocaleDateString()}
</p> </p>
) : ( ) : (
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">Expected harvest date not available</p>
Expected harvest date not available
</p>
)} )}
</div> </div>
</div> </div>
@ -208,138 +265,123 @@ export default function CropDetailPage({
<Button <Button
key={action.title} key={action.title}
variant="outline" variant="outline"
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105`} className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105 border-border/30`}
onClick={action.onClick} onClick={action.onClick}>
>
<div <div
className={`p-3 rounded-lg ${action.color} group-hover:scale-110 transition-transform`} className={`p-3 rounded-lg ${action.color.replace(
> "text-",
"bg-"
)}/20 group-hover:scale-110 transition-transform`}>
<action.icon className="h-5 w-5" /> <action.icon className="h-5 w-5" />
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="font-medium mb-1">{action.title}</div> <div className="font-medium mb-1">{action.title}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{action.description}</p>
{action.description}
</p>
</div> </div>
</Button> </Button>
))} ))}
</div> </div>
{/* Environmental Metrics */} {/* Environmental Metrics */}
<Card className="border-green-100 dark:border-green-700"> <Card className="border-border/30">
<CardHeader> <CardHeader>
<CardTitle>Environmental Conditions</CardTitle> <CardTitle>Environmental Conditions</CardTitle>
<CardDescription> <CardDescription>Real-time monitoring data</CardDescription>
Real-time monitoring of growing conditions
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-6"> <div className="grid gap-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[ {[
{ {
icon: ThermometerSun, icon: ThermometerSun,
label: "Temperature", label: "Temperature",
value: `${analytics.temperature}°C`, value: analytics?.temperature ? `${analytics.temperature}°C` : "N/A",
color: "text-orange-500 dark:text-orange-300", color: "text-orange-500 dark:text-orange-300",
bg: "bg-orange-50 dark:bg-orange-900", bg: "bg-orange-50 dark:bg-orange-900",
}, },
{ {
icon: Droplets, icon: Droplets,
label: "Humidity", label: "Humidity",
value: `${analytics.humidity}%`, value: analytics?.humidity ? `${analytics.humidity}%` : "N/A",
color: "text-blue-500 dark:text-blue-300", color: "text-blue-500 dark:text-blue-300",
bg: "bg-blue-50 dark:bg-blue-900", bg: "bg-blue-50 dark:bg-blue-900",
}, },
{ {
icon: Sun, icon: Sun,
label: "Sunlight", label: "Sunlight",
value: `${analytics.sunlight}%`, value: analytics?.sunlight ? `${analytics.sunlight}%` : "N/A",
color: "text-yellow-500 dark:text-yellow-300", color: "text-yellow-500 dark:text-yellow-300",
bg: "bg-yellow-50 dark:bg-yellow-900", bg: "bg-yellow-50 dark:bg-yellow-900",
}, },
{ {
icon: Leaf, icon: Leaf,
label: "Soil Moisture", label: "Soil Moisture",
value: `${analytics.soilMoisture}%`, value: analytics?.soilMoisture ? `${analytics.soilMoisture}%` : "N/A",
color: "text-green-500 dark:text-green-300", color: "text-green-500 dark:text-green-300",
bg: "bg-green-50 dark:bg-green-900", bg: "bg-green-50 dark:bg-green-900",
}, },
{ {
icon: Wind, icon: Wind,
label: "Wind Speed", label: "Wind Speed",
value: analytics.windSpeed, value: analytics?.windSpeed ?? "N/A",
color: "text-gray-500 dark:text-gray-300", color: "text-gray-500 dark:text-gray-300",
bg: "bg-gray-50 dark:bg-gray-900", bg: "bg-gray-50 dark:bg-gray-900",
}, },
{ {
icon: CloudRain, icon: CloudRain,
label: "Rainfall", label: "Rainfall",
value: analytics.rainfall, value: analytics?.rainfall ?? "N/A",
color: "text-indigo-500 dark:text-indigo-300", color: "text-indigo-500 dark:text-indigo-300",
bg: "bg-indigo-50 dark:bg-indigo-900", bg: "bg-indigo-50 dark:bg-indigo-900",
}, },
].map((metric) => ( ].map((metric) => (
<Card <Card key={metric.label} className="border-border/30 shadow-none bg-card dark:bg-slate-800">
key={metric.label}
className="border-none shadow-none bg-gradient-to-br from-white to-gray-50/50 dark:from-slate-800 dark:to-slate-700/50"
>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${metric.bg}`}> <div className={`p-2 rounded-lg ${metric.bg}/50`}>
<metric.icon <metric.icon className={`h-4 w-4 ${metric.color}`} />
className={`h-4 w-4 ${metric.color}`}
/>
</div> </div>
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">{metric.label}</p>
{metric.label} <p className="text-xl font-semibold tracking-tight">{metric.value}</p>
</p>
<p className="text-2xl font-semibold tracking-tight">
{metric.value}
</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
<Separator /> <Separator />
{/* Growth Progress */} {/* Growth Progress */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="font-medium">Growth Progress</span> <span className="font-medium">Growth Progress</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">{growthProgress}%</span>
{analytics.growthProgress}%
</span>
</div> </div>
<Progress <Progress value={growthProgress} className="h-2" />
value={analytics.growthProgress} <p className="text-xs text-muted-foreground">
className="h-2" Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity.
/> </p>
</div> </div>
{/* Next Action Card */} {/* Next Action Card */}
<Card className="border-green-100 dark:border-green-700 bg-green-50/50 dark:bg-green-900/50"> <Card className="border-blue-100 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-900/50">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-800"> <div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-800">
<Timer className="h-4 w-4 text-green-600 dark:text-green-300" /> <Timer className="h-4 w-4 text-blue-600 dark:text-blue-300" />
</div> </div>
<div> <div>
<p className="font-medium mb-1"> <p className="font-medium mb-1">Next Action Required</p>
Next Action Required
</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{analytics.nextAction} {analytics?.nextAction || "Check crop status"}
</p>
<p className="text-xs text-muted-foreground mt-1">
Due by{" "}
{analytics.nextActionDue.toLocaleDateString()}
</p> </p>
{analytics?.nextActionDue && (
<p className="text-xs text-muted-foreground mt-1">
Due by {new Date(analytics.nextActionDue).toLocaleDateString()}
</p>
)}
{!analytics?.nextAction && (
<p className="text-xs text-muted-foreground mt-1">No immediate actions required.</p>
)}
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -349,13 +391,18 @@ export default function CropDetailPage({
</Card> </Card>
{/* Map Section */} {/* Map Section */}
<Card className="border-green-100 dark:border-green-700"> <Card className="border-border/30">
<CardHeader> <CardHeader>
<CardTitle>Field Map</CardTitle> <CardTitle>Cropland Location / Boundary</CardTitle>
<CardDescription>View and manage crop location</CardDescription> <CardDescription>Visual representation on the farm</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0 h-[400px]"> <CardContent className="p-0 h-[400px] overflow-hidden rounded-b-lg">
<GoogleMapWithDrawing /> {/* TODO: Ensure GoogleMapWithDrawing correctly parses and displays GeoFeatureData */}
<GoogleMapWithDrawing
initialFeatures={cropland.geoFeature ? [cropland.geoFeature] : undefined}
drawingMode={null}
editable={false}
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -363,83 +410,64 @@ export default function CropDetailPage({
{/* Right Column */} {/* Right Column */}
<div className="md:col-span-4 space-y-6"> <div className="md:col-span-4 space-y-6">
{/* Nutrient Levels */} {/* Nutrient Levels */}
<Card className="border-green-100 dark:border-green-700"> <Card className="border-border/30">
<CardHeader> <CardHeader>
<CardTitle>Nutrient Levels</CardTitle> <CardTitle className="flex items-center gap-2">
<CardDescription>Current soil composition</CardDescription> <LeafIcon className="h-5 w-5 text-amber-600 dark:text-amber-400" />
Nutrient Levels
</CardTitle>
<CardDescription>Soil composition (if available)</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{[ {[
{ {
name: "Nitrogen (N)", name: "Nitrogen (N)",
value: analytics.nutrientLevels.nitrogen, value: analytics?.nutrientLevels?.nitrogen,
color: "bg-blue-500 dark:bg-blue-700", color: "bg-blue-500 dark:bg-blue-700",
}, },
{ {
name: "Phosphorus (P)", name: "Phosphorus (P)",
value: analytics.nutrientLevels.phosphorus, value: analytics?.nutrientLevels?.phosphorus,
color: "bg-yellow-500 dark:bg-yellow-700", color: "bg-yellow-500 dark:bg-yellow-700",
}, },
{ {
name: "Potassium (K)", name: "Potassium (K)",
value: analytics.nutrientLevels.potassium, value: analytics?.nutrientLevels?.potassium,
color: "bg-green-500 dark:bg-green-700", color: "bg-green-500 dark:bg-green-700",
}, },
].map((nutrient) => ( ].map((nutrient) => (
<div key={nutrient.name} className="space-y-2"> <div key={nutrient.name} className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="font-medium">{nutrient.name}</span> <span className="font-medium">{nutrient.name}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">{nutrient.value ?? "N/A"}%</span>
{nutrient.value}%
</span>
</div> </div>
<Progress <Progress
value={nutrient.value} value={nutrient.value ?? 0}
className={`h-2 ${nutrient.color}`} className={`h-2 ${
nutrient.value !== null && nutrient.value !== undefined ? nutrient.color : "bg-muted"
}`}
/> />
</div> </div>
))} ))}
{!analytics?.nutrientLevels && (
<p className="text-center text-sm text-muted-foreground py-4">Nutrient data not available.</p>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Recent Activity */} {/* Recent Activity */}
<Card className="border-green-100 dark:border-green-700"> <Card className="border-border/30">
<CardHeader> <CardHeader>
<CardTitle>Recent Activity</CardTitle> <CardTitle className="flex items-center gap-2">
<CardDescription>Latest updates and changes</CardDescription> <History className="h-5 w-5 text-purple-600 dark:text-purple-400" />
Recent Activity
</CardTitle>
<CardDescription>Latest updates (placeholder)</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ScrollArea className="h-[300px] pr-4"> <ScrollArea className="h-[300px] pr-4">
{[...Array(5)].map((_, i) => ( <div className="text-center py-10 text-muted-foreground">No recent activity logged.</div>
<div key={i} className="mb-4 last:mb-0">
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800">
<Activity icon={i} />
</div>
<div>
<p className="text-sm font-medium">
{
[
"Irrigation completed",
"Nutrient levels checked",
"Growth measurement taken",
"Pest inspection completed",
"Soil pH tested",
][i]
}
</p>
<p className="text-xs text-muted-foreground">
2 hours ago
</p>
</div>
</div>
{i < 4 && (
<Separator className="my-4 dark:bg-slate-700" />
)}
</div>
))}
</ScrollArea> </ScrollArea>
</CardContent> </CardContent>
</Card> </Card>
@ -447,38 +475,30 @@ export default function CropDetailPage({
</div> </div>
{/* Dialogs */} {/* Dialogs */}
<ChatbotDialog <ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={cropland.name || "this crop"} />
open={isChatOpen} {/* Ensure AnalyticsDialog uses the correct props */}
onOpenChange={setIsChatOpen} {analytics && (
cropName={crop.name} <AnalyticsDialog
/> open={isAnalyticsOpen}
<AnalyticsDialog onOpenChange={setIsAnalyticsOpen}
open={isAnalyticsOpen} // The dialog expects a `Crop` type, but we have `Cropland` and `CropAnalytics`
onOpenChange={setIsAnalyticsOpen} // We need to construct a simplified `Crop` object or update the dialog prop type
crop={crop} crop={{
analytics={analytics} // Constructing a simplified Crop object
/> uuid: cropland.uuid,
farmId: cropland.farmId,
name: cropland.name,
createdAt: cropland.createdAt, // Use createdAt as plantedDate
status: cropland.status,
variety: plant?.variety, // Get from plant data
area: `${cropland.landSize} ha`, // Convert landSize
progress: growthProgress, // Use calculated/fetched progress
// healthScore might map to plantHealth
}}
analytics={analytics} // Pass fetched analytics
/>
)}
</div> </div>
</div> </div>
); );
} }
/**
* Helper component to render an activity icon based on the index.
*/
function Activity({ icon }: { icon: number }) {
const icons = [
<Droplets key="0" className="h-4 w-4 text-blue-500 dark:text-blue-300" />,
<Leaf key="1" className="h-4 w-4 text-green-500 dark:text-green-300" />,
<LineChart
key="2"
className="h-4 w-4 text-purple-500 dark:text-purple-300"
/>,
<Sprout key="3" className="h-4 w-4 text-yellow-500 dark:text-yellow-300" />,
<ThermometerSun
key="4"
className="h-4 w-4 text-orange-500 dark:text-orange-300"
/>,
];
return icons[icon];
}

View File

@ -1,463 +1,406 @@
// "use client"; "use client";
// import React, { useState, useEffect } from "react"; import React, { useMemo, useState } from "react";
// import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
// import { import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// ArrowLeft, import {
// MapPin, ArrowLeft,
// Plus, MapPin,
// Sprout, Plus,
// Calendar, Sprout,
// LayoutGrid, Calendar,
// AlertTriangle, LayoutGrid,
// Loader2, AlertTriangle,
// Home, Loader2,
// ChevronRight, Home,
// Droplets, ChevronRight,
// Sun, Sun,
// Wind, } from "lucide-react";
// } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion";
// import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { useParams } from "next/navigation";
// import { Button } from "@/components/ui/button";
// import { CropDialog } from "./crop-dialog";
// import { CropCard } from "./crop-card";
// 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";
// /** import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
// * Used in Next.js; params is now a Promise and must be unwrapped with React.use() import { Button } from "@/components/ui/button";
// */ import { CropDialog } from "./crop-dialog";
// interface FarmDetailPageProps { import { CropCard } from "./crop-card";
// params: Promise<{ farmId: string }>; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
// } import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Farm, Cropland } from "@/types";
import { getCropsByFarmId, createCrop, CropResponse } from "@/api/crop";
import { getFarm } from "@/api/farm";
// export default function FarmDetailPage({ params }: FarmDetailPageProps) { // ===================================================================
// // Unwrap the promised params using React.use() (experimental) // Page Component: FarmDetailPage
// const resolvedParams = React.use(params); // - Manages farm details, crop listings, filter tabs, and crop creation.
// - Performs API requests via React Query.
// ===================================================================
export default function FarmDetailPage() {
// ---------------------------------------------------------------
// Routing and URL Params
// ---------------------------------------------------------------
const params = useParams<{ farmId: string }>();
const farmId = params.farmId;
const router = useRouter();
const queryClient = useQueryClient();
// const router = useRouter(); // ---------------------------------------------------------------
// const [farm, setFarm] = useState<Farm | null>(null); // Local State for dialog and crop filter management
// const [crops, setCrops] = useState<Crop[]>([]); // ---------------------------------------------------------------
// const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
// const [isLoading, setIsLoading] = useState(true); const [activeFilter, setActiveFilter] = useState<string>("all");
// const [error, setError] = useState<string | null>(null);
// const [activeFilter, setActiveFilter] = useState<string>("all");
// // Fetch farm details on initial render using the resolved params // ---------------------------------------------------------------
// useEffect(() => { // Data fetching: Farm details and Crops using React Query.
// async function loadFarmDetails() { // - See: https://tanstack.com/query
// try { // ---------------------------------------------------------------
// setIsLoading(true); const {
// setError(null); data: farm,
// const { farm, crops } = await fetchFarmDetails(resolvedParams.farmId); isLoading: isLoadingFarm,
// setFarm(farm); isError: isErrorFarm,
// setCrops(crops); error: errorFarm,
// } catch (err) { } = useQuery<Farm>({
// if (err instanceof Error) { queryKey: ["farm", farmId],
// if (err.message === "FARM_NOT_FOUND") { queryFn: () => getFarm(farmId),
// router.push("/not-found"); enabled: !!farmId,
// return; staleTime: 60 * 1000,
// } });
// setError(err.message);
// } else {
// setError("An unknown error occurred");
// }
// } finally {
// setIsLoading(false);
// }
// }
// loadFarmDetails(); const {
// }, [resolvedParams.farmId, router]); data: cropData, // Changed name to avoid conflict
isLoading: isLoadingCrops,
isError: isErrorCrops,
error: errorCrops,
} = useQuery<CropResponse>({
// Use CropResponse type
queryKey: ["crops", farmId],
queryFn: () => getCropsByFarmId(farmId), // Use updated API function name
enabled: !!farmId,
staleTime: 60 * 1000,
});
// /** // ---------------------------------------------------------------
// * Handles adding a new crop. // Mutation: Create Crop
// */ // - After creation, invalidate queries to refresh data.
// const handleAddCrop = async (data: Partial<Crop>) => { // ---------------------------------------------------------------
// try { const croplands = useMemo(() => cropData?.croplands || [], [cropData]);
// // Simulate API delay
// await new Promise((resolve) => setTimeout(resolve, 800));
// const newCrop: Crop = { const mutation = useMutation({
// id: Math.random().toString(36).substr(2, 9), mutationFn: (newCropData: Partial<Cropland>) => createCrop({ ...newCropData, farmId: farmId }), // Pass farmId here
// farmId: farm!.id, onSuccess: (newlyCreatedCrop) => {
// name: data.name!, console.log("Successfully created crop:", newlyCreatedCrop);
// plantedDate: data.plantedDate!, queryClient.invalidateQueries({ queryKey: ["crops", farmId] });
// status: data.status!, queryClient.invalidateQueries({ queryKey: ["farm", farmId] }); // Invalidate farm too to update crop count potentially
// variety: data.variety || "Standard", setIsDialogOpen(false);
// area: data.area || "0 hectares", },
// healthScore: data.status === "growing" ? 85 : 0, onError: (error) => {
// progress: data.status === "growing" ? 10 : 0, console.error("Failed to add crop:", error);
// }; // TODO: Show user-friendly error message (e.g., using toast)
},
});
// setCrops((prev) => [newCrop, ...prev]); const handleAddCrop = async (data: Partial<Cropland>) => {
await mutation.mutateAsync(data);
};
// // Update the farm's crop count // ---------------------------------------------------------------
// if (farm) { // Determine combined loading and error states from individual queries.
// setFarm({ ...farm, crops: farm.crops + 1 }); // ---------------------------------------------------------------
// } const isLoading = isLoadingFarm || isLoadingCrops;
const isError = isErrorFarm || isErrorCrops;
const error = errorFarm || errorCrops;
// setIsDialogOpen(false); // ---------------------------------------------------------------
// } catch (err) { // Filter crops based on the active filter tab.
// setError("Failed to add crop. Please try again."); // ---------------------------------------------------------------
// } const filteredCrops = useMemo(() => {
// }; // Renamed from filteredCrops
return croplands.filter(
(crop) => activeFilter === "all" || crop.status.toLowerCase() === activeFilter.toLowerCase() // Use camelCase status
);
}, [croplands, activeFilter]);
// // Filter crops based on the active filter // ---------------------------------------------------------------
// const filteredCrops = crops.filter((crop) => activeFilter === "all" || crop.status === activeFilter); // Calculate counts for each crop status to display in tabs.
// ---------------------------------------------------------------
const possibleStatuses = ["growing", "planned", "harvested", "fallow"]; // Use lowercase
const cropCounts = useMemo(() => {
return croplands.reduce(
(acc, crop) => {
const status = crop.status.toLowerCase(); // Use camelCase status
if (acc[status] !== undefined) {
acc[status]++;
} else {
acc["other"] = (acc["other"] || 0) + 1; // Count unknown statuses
}
acc.all++;
return acc;
},
{ all: 0, ...Object.fromEntries(possibleStatuses.map((s) => [s, 0])) } as Record<string, number>
);
}, [croplands]);
// // Calculate crop counts grouped by status // ---------------------------------------------------------------
// const cropCounts = { // Derive the unique statuses from the crops list for the tabs.
// all: crops.length, // ---------------------------------------------------------------
// growing: crops.filter((crop) => crop.status === "growing").length, const availableStatuses = useMemo(() => {
// planned: crops.filter((crop) => crop.status === "planned").length, return ["all", ...new Set(croplands.map((crop) => crop.status.toLowerCase()))]; // Use camelCase status
// harvested: crops.filter((crop) => crop.status === "harvested").length, }, [croplands]);
// };
// return ( // ===================================================================
// <div className="min-h-screen bg-background text-foreground"> // Render: Main page layout segmented into breadcrumbs, farm cards,
// <div className="container max-w-7xl p-6 mx-auto"> // crop management, and the crop add dialog.
// <div className="flex flex-col gap-6"> // ===================================================================
// {/* Breadcrumbs */} return (
// <nav className="flex items-center text-sm text-muted-foreground"> <div className="min-h-screen bg-background text-foreground">
// <Button <div className="container max-w-7xl p-6 mx-auto">
// variant="link" <div className="flex flex-col gap-6">
// className="p-0 h-auto font-normal text-muted-foreground" {/* ------------------------------
// onClick={() => router.push("/")}> Breadcrumbs Navigation Section
// <Home className="h-3.5 w-3.5 mr-1" /> ------------------------------ */}
// Home <nav className="flex items-center text-sm text-muted-foreground">
// </Button> <Button
// <ChevronRight className="h-3.5 w-3.5 mx-1" /> variant="link"
// <Button className="p-0 h-auto font-normal text-muted-foreground"
// variant="link" onClick={() => router.push("/")}>
// className="p-0 h-auto font-normal text-muted-foreground" <Home className="h-3.5 w-3.5 mr-1" />
// onClick={() => router.push("/farms")}> Home
// Farms </Button>
// </Button> <ChevronRight className="h-3.5 w-3.5 mx-1" />
// <ChevronRight className="h-3.5 w-3.5 mx-1" /> <Button
// <span className="text-foreground font-medium truncate">{farm?.name || "Farm Details"}</span> variant="link"
// </nav> 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 Back Navigation Button
// variant="outline" ------------------------------ */}
// size="sm" <Button
// className="w-fit gap-2 text-muted-foreground" variant="outline"
// onClick={() => router.push("/farms")}> size="sm"
// <ArrowLeft className="h-4 w-4" /> Back to Farms className="w-fit gap-2 text-muted-foreground"
// </Button> onClick={() => router.push("/farms")}>
<ArrowLeft className="h-4 w-4" /> Back to Farms
</Button>
// {/* Error state */} {/* ------------------------------
// {error && ( Error and Loading States
// <Alert variant="destructive"> ------------------------------ */}
// <AlertTriangle className="h-4 w-4" /> {isError && !isLoadingFarm && !farm && (
// <AlertTitle>Error</AlertTitle> <Alert variant="destructive">
// <AlertDescription>{error}</AlertDescription> <AlertTriangle className="h-4 w-4" />
// </Alert> <AlertTitle>Error Loading Farm</AlertTitle>
// )} <AlertDescription>{(error as Error)?.message || "Could not load farm details."}</AlertDescription>
</Alert>
)}
{isErrorCrops && (
<Alert variant="destructive" className="mt-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error Loading Crops</AlertTitle>
<AlertDescription>{(errorCrops as Error)?.message || "Could not load crop data."}</AlertDescription>
</Alert>
)}
{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>
)}
// {/* Loading state */} {/* ------------------------------
// {isLoading && ( Farm Details and Statistics
// <div className="flex flex-col items-center justify-center py-12"> ------------------------------ */}
// <Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" /> {!isLoadingFarm && !isErrorFarm && farm && (
// <p className="text-muted-foreground">Loading farm details...</p> <>
// </div> <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.farmType}
</Badge>
<div className="flex items-center text-sm text-muted-foreground">
<Calendar className="h-4 w-4 mr-1" />
Created {new Date(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" />
Lat: {farm.lat?.toFixed(4)}, Lon: {farm.lon?.toFixed(4)}
</div>
</div>
</div>
</CardHeader>
<CardContent>
<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.totalSize}</p>
</div>
<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">{isLoadingCrops ? "..." : cropCounts.all ?? 0}</p>
</div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Growing</p>
<p className="text-lg font-semibold">{isLoadingCrops ? "..." : cropCounts.growing ?? 0}</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">{isLoadingCrops ? "..." : cropCounts.harvested ?? 0}</p>
</div>
</div>
</CardContent>
</Card>
// {/* Farm details */} {/* Weather Overview Card */}
// {!isLoading && !error && farm && ( <Card className="md:col-span-4">
// <> <CardHeader>
// <div className="grid gap-6 md:grid-cols-12"> <CardTitle className="text-lg font-semibold flex items-center">
// {/* Farm info card */} <Sun className="h-5 w-5 mr-2 text-yellow-500" /> Weather Overview
// <Card className="md:col-span-8"> </CardTitle>
// <CardHeader className="pb-2"> <CardDescription>Current conditions</CardDescription>
// <div className="flex items-center justify-between"> </CardHeader>
// <Badge <CardContent className="space-y-3">
// variant="outline" <div className="flex justify-between items-center">
// className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200"> <span className="text-muted-foreground">Temperature</span>
// {farm.type} <span className="font-medium">25°C</span>
// </Badge> </div>
// <div className="flex items-center text-sm text-muted-foreground"> <div className="flex justify-between items-center">
// <Calendar className="h-4 w-4 mr-1" /> <span className="text-muted-foreground">Humidity</span>
// Created {farm.createdAt.toLocaleDateString()} <span className="font-medium">60%</span>
// </div> </div>
// </div> <div className="flex justify-between items-center">
// <div className="flex items-start gap-4 mt-2"> <span className="text-muted-foreground">Wind</span>
// <div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-800 flex items-center justify-center"> <span className="font-medium">10 km/h</span>
// <Sprout className="h-6 w-6 text-green-600 dark:text-green-300" /> </div>
// </div> <div className="flex justify-between items-center">
// <div> <span className="text-muted-foreground">Rainfall (24h)</span>
// <h1 className="text-2xl font-bold">{farm.name}</h1> <span className="font-medium">2 mm</span>
// <div className="flex items-center text-muted-foreground mt-1"> </div>
// <MapPin className="h-4 w-4 mr-1" /> </CardContent>
// {farm.location} </Card>
// </div> </div>
// </div>
// </div>
// </CardHeader>
// <CardContent>
// <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="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="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>
// {/* Weather card */} {/* ------------------------------
// <Card className="md:col-span-4"> Crops Section: List and Filtering Tabs
// <CardHeader> ------------------------------ */}
// <CardTitle className="text-lg">Current Conditions</CardTitle> <div className="mt-4">
// <CardDescription>Weather at your farm location</CardDescription> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
// </CardHeader> <div>
// <CardContent className="space-y-4"> <h2 className="text-xl font-bold flex items-center">
// <div className="grid grid-cols-2 gap-4"> <LayoutGrid className="h-5 w-5 mr-2 text-green-600 dark:text-green-300" />
// <div className="flex items-start gap-2"> Crops / Croplands
// <div className="p-2 rounded-lg bg-orange-50 dark:bg-orange-900"> </h2>
// <Sun className="h-4 w-4 text-orange-500 dark:text-orange-200" /> <p className="text-sm text-muted-foreground">Manage and monitor all croplands in this farm</p>
// </div> </div>
// <div> <Button
// <p className="text-sm font-medium text-muted-foreground">Temperature</p> onClick={() => setIsDialogOpen(true)}
// <p className="text-xl font-semibold">{farm.weather?.temperature}°C</p> className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto"
// </div> disabled={mutation.isPending}>
// </div> {mutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
// <div className="flex items-start gap-2"> Add New Crop
// <div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900"> </Button>
// <Droplets className="h-4 w-4 text-blue-500 dark:text-blue-200" /> </div>
// </div> {mutation.isError && (
// <div> <Alert variant="destructive" className="mb-4">
// <p className="text-sm font-medium text-muted-foreground">Humidity</p> <AlertTriangle className="h-4 w-4" />
// <p className="text-xl font-semibold">{farm.weather?.humidity}%</p> <AlertTitle>Failed to Add Crop</AlertTitle>
// </div> <AlertDescription>
// </div> {(mutation.error as Error)?.message || "Could not add the crop. Please try again."}
// <div className="flex items-start gap-2"> </AlertDescription>
// <div className="p-2 rounded-lg bg-yellow-50 dark:bg-yellow-900"> </Alert>
// <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>
// </div>
// </CardContent>
// </Card>
// </div>
// {/* Crops section */} <Tabs value={activeFilter} onValueChange={setActiveFilter} className="mt-6">
// <div className="mt-4"> <TabsList>
// <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4"> {availableStatuses.map((status) => (
// <div> <TabsTrigger key={status} value={status} className="capitalize">
// <h2 className="text-xl font-bold flex items-center"> {status === "all" ? "All" : status} ({isLoadingCrops ? "..." : cropCounts[status] ?? 0})
// <LayoutGrid className="h-5 w-5 mr-2 text-green-600 dark:text-green-300" /> </TabsTrigger>
// Crops ))}
// </h2> </TabsList>
// <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>
// <Tabs defaultValue="all" className="mt-6"> {isLoadingCrops ? (
// <TabsList> <div className="flex justify-center py-12">
// <TabsTrigger value="all" onClick={() => setActiveFilter("all")}> <Loader2 className="h-6 w-6 text-green-600 animate-spin" />
// All Crops ({cropCounts.all}) </div>
// </TabsTrigger> ) : isErrorCrops ? (
// <TabsTrigger value="growing" onClick={() => setActiveFilter("growing")}> <div className="text-center py-12 text-destructive">Failed to load crops.</div>
// Growing ({cropCounts.growing}) ) : (
// </TabsTrigger> availableStatuses.map((status) => (
// <TabsTrigger value="planned" onClick={() => setActiveFilter("planned")}> <TabsContent key={status} value={status} className="mt-6">
// Planned ({cropCounts.planned}) {filteredCrops.length === 0 && activeFilter === status ? (
// </TabsTrigger> <div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
// <TabsTrigger value="harvested" onClick={() => setActiveFilter("harvested")}> <div className="bg-green-100 dark:bg-green-900 p-3 rounded-full mb-4">
// Harvested ({cropCounts.harvested}) <Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
// </TabsTrigger> </div>
// </TabsList> <h3 className="text-xl font-medium mb-2">
No {status === "all" ? "" : status} crops found
</h3>
<p className="text-muted-foreground text-center max-w-md mb-6">
{status === "all"
? "You haven't added any crops to this farm yet."
: `No crops with status "${status}" found.`}
</p>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
Add {status === "all" ? "your first" : "a new"} crop
</Button>
</div>
) : activeFilter === status && filteredCrops.length > 0 ? (
<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.uuid}
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/${farmId}/crops/${crop.uuid}`)}
/>
</motion.div>
))}
</AnimatePresence>
</div>
) : null}
</TabsContent>
))
)}
</Tabs>
</div>
</>
)}
</div>
</div>
// <TabsContent value="all" className="mt-6"> {/* ------------------------------
// {filteredCrops.length === 0 ? ( Add Crop Dialog Component
// <div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed"> - Passes the mutation state to display loading indicators.
// <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" /> <CropDialog
// </div> open={isDialogOpen}
// <h3 className="text-xl font-medium mb-2">No crops found</h3> onOpenChange={setIsDialogOpen}
// <p className="text-muted-foreground text-center max-w-md mb-6"> onSubmit={handleAddCrop}
// {activeFilter === "all" isSubmitting={mutation.isPending}
// ? "You haven't added any crops to this farm yet." />
// : `No ${activeFilter} crops found. Try a different filter.`} </div>
// </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}`)}
// />
// </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>
// {/* Add Crop Dialog */}
// <CropDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} onSubmit={handleAddCrop} />
// </div>
// );
// }
export default function FarmDetailPage({ params }: FarmDetailPageProps) {
return <div>hello</div>;
} }

View File

@ -71,11 +71,11 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
const farmData: Partial<Farm> = { const farmData: Partial<Farm> = {
Name: values.name, name: values.name,
Lat: values.latitude, lat: values.latitude,
Lon: values.longitude, lon: values.longitude,
FarmType: values.type, farmType: values.type,
TotalSize: values.area, totalSize: values.area,
}; };
await onSubmit(farmData); await onSubmit(farmData);
form.reset(); form.reset();

View File

@ -9,7 +9,7 @@ import type { Farm } from "@/types";
export interface FarmCardProps { export interface FarmCardProps {
variant: "farm" | "add"; variant: "farm" | "add";
farm?: Farm; farm?: Farm; // Use updated Farm type
onClick?: () => void; onClick?: () => void;
} }
@ -40,7 +40,7 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}).format(new Date(farm.CreatedAt)); }).format(new Date(farm.createdAt));
return ( return (
<Card className={cardClasses} onClick={onClick}> <Card className={cardClasses} onClick={onClick}>
@ -49,7 +49,7 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
<Badge <Badge
variant="outline" variant="outline"
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200"> className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200">
{farm.FarmType} {farm.farmType}
</Badge> </Badge>
<div className="flex items-center text-xs text-muted-foreground"> <div className="flex items-center text-xs text-muted-foreground">
<CalendarDays className="h-3 w-3 mr-1" /> <CalendarDays className="h-3 w-3 mr-1" />
@ -63,19 +63,19 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
<Sprout className="h-5 w-5 text-green-600" /> <Sprout className="h-5 w-5 text-green-600" />
</div> </div>
<div> <div>
<h3 className="text-xl font-medium mb-1 truncate">{farm.Name}</h3> <h3 className="text-xl font-medium mb-1 truncate">{farm.name}</h3>
<div className="flex items-center text-sm text-muted-foreground mb-2"> <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" /> <MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
<span className="truncate">{farm.Lat}</span> <span className="truncate">{farm.lat}</span>
</div> </div>
<div className="grid grid-cols-2 gap-2 mt-3"> <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"> <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="text-xs text-muted-foreground">Area</p>
<p className="font-medium">{farm.TotalSize}</p> <p className="font-medium">{farm.totalSize}</p>
</div> </div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center"> <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="text-xs text-muted-foreground">Crops</p>
<p className="font-medium">{farm.Crops ? farm.Crops.length : 0}</p> <p className="font-medium">{farm.crops ? farm.crops.length : 0}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -36,18 +36,21 @@ export default function FarmSetupPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const { const {
data: farms, data: farms, // Type is Farm[] now
isLoading, isLoading,
isError, isError,
error, error,
} = useQuery<Farm[]>({ } = useQuery<Farm[]>({
// Use Farm[] type
queryKey: ["farms"], queryKey: ["farms"],
queryFn: fetchFarms, queryFn: fetchFarms,
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: Partial<Farm>) => createFarm(data), // Pass the correct type to createFarm
mutationFn: (data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) =>
createFarm(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["farms"] }); queryClient.invalidateQueries({ queryKey: ["farms"] });
setIsDialogOpen(false); setIsDialogOpen(false);
@ -69,23 +72,23 @@ export default function FarmSetupPage() {
const filteredAndSortedFarms = (farms || []) const filteredAndSortedFarms = (farms || [])
.filter( .filter(
(farm) => (farm) =>
(activeFilter === "all" || farm.FarmType === activeFilter) && (activeFilter === "all" || farm.farmType === activeFilter) && // Use camelCase farmType
(farm.Name.toLowerCase().includes(searchQuery.toLowerCase()) || (farm.name.toLowerCase().includes(searchQuery.toLowerCase()) || // Use camelCase name
// farm.location.toLowerCase().includes(searchQuery.toLowerCase()) || // farm.location is no longer a single string, use lat/lon if needed for search
farm.FarmType.toLowerCase().includes(searchQuery.toLowerCase())) farm.farmType.toLowerCase().includes(searchQuery.toLowerCase())) // Use camelCase farmType
) )
.sort((a, b) => { .sort((a, b) => {
if (sortOrder === "newest") { if (sortOrder === "newest") {
return new Date(b.CreatedAt).getTime() - new Date(a.CreatedAt).getTime(); return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); // Use camelCase createdAt
} else if (sortOrder === "oldest") { } else if (sortOrder === "oldest") {
return new Date(a.CreatedAt).getTime() - new Date(b.CreatedAt).getTime(); return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); // Use camelCase createdAt
} else { } else {
return a.Name.localeCompare(b.Name); return a.name.localeCompare(b.name); // Use camelCase name
} }
}); });
// Get distinct farm types. // Get distinct farm types.
const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.FarmType))]; const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.farmType))]; // Use camelCase farmType
const handleAddFarm = async (data: Partial<Farm>) => { const handleAddFarm = async (data: Partial<Farm>) => {
await mutation.mutateAsync(data); await mutation.mutateAsync(data);
@ -133,6 +136,7 @@ export default function FarmSetupPage() {
</Badge> </Badge>
))} ))}
</div> </div>
{/* DropdownMenu remains the same, Check icon was missing */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="outline" className="gap-2">
@ -228,23 +232,17 @@ export default function FarmSetupPage() {
{!isLoading && !isError && filteredAndSortedFarms.length > 0 && ( {!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"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence> <AnimatePresence>
<motion.div <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)} /> <FarmCard variant="add" onClick={() => setIsDialogOpen(true)} />
</motion.div> </motion.div>
{filteredAndSortedFarms.map((farm, index) => ( {filteredAndSortedFarms.map((farm, index) => (
<motion.div <motion.div
key={farm.UUID} key={farm.uuid} // Use camelCase uuid initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: index * 0.05 }} transition={{ duration: 0.2, delay: index * 0.05 }}
className="col-span-1"> className="col-span-1">
<FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.UUID}`)} /> <FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.uuid}`)} />
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
@ -260,6 +258,7 @@ export default function FarmSetupPage() {
<DialogTitle>Add New Farm</DialogTitle> <DialogTitle>Add New Farm</DialogTitle>
<DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription> <DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription>
</DialogHeader> </DialogHeader>
{/* Pass handleAddFarm (which now expects Partial<Farm>) */}
<AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} /> <AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -107,10 +107,11 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
async function getUser() { async function getUser() {
try { try {
const data = await fetchUserMe(); const data = await fetchUserMe();
console.log(data);
setUser({ setUser({
name: data.user.UUID, name: data.user.uuid,
email: data.user.Email, email: data.user.email,
avatar: data.user.Avatar || "/avatars/avatar.webp", avatar: data.user.avatar || "/avatars/avatar.webp",
}); });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {

View File

@ -31,6 +31,7 @@
"@react-oauth/google": "^0.12.1", "@react-oauth/google": "^0.12.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@tanstack/react-table": "^8.21.2",
"@vis.gl/react-google-maps": "^1.5.2", "@vis.gl/react-google-maps": "^1.5.2",
"axios": "^1.7.9", "axios": "^1.7.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -74,6 +74,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.66.0 specifier: ^5.66.0
version: 5.67.3(react@19.0.0) version: 5.67.3(react@19.0.0)
'@tanstack/react-table':
specifier: ^8.21.2
version: 8.21.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@vis.gl/react-google-maps': '@vis.gl/react-google-maps':
specifier: ^1.5.2 specifier: ^1.5.2
version: 1.5.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 1.5.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -119,6 +122,9 @@ importers:
react-hook-form: react-hook-form:
specifier: ^7.54.2 specifier: ^7.54.2
version: 7.54.2(react@19.0.0) version: 7.54.2(react@19.0.0)
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.0.0)
recharts: recharts:
specifier: ^2.15.1 specifier: ^2.15.1
version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -974,6 +980,17 @@ packages:
peerDependencies: peerDependencies:
react: ^18 || ^19 react: ^18 || ^19
'@tanstack/react-table@8.21.2':
resolution: {integrity: sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.21.2':
resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==}
engines: {node: '>=12'}
'@types/d3-array@3.2.1': '@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@ -2294,6 +2311,11 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
react-icons@5.5.0:
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
peerDependencies:
react: '*'
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -3500,6 +3522,14 @@ snapshots:
'@tanstack/query-core': 5.67.3 '@tanstack/query-core': 5.67.3
react: 19.0.0 react: 19.0.0
'@tanstack/react-table@8.21.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@tanstack/table-core': 8.21.2
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@tanstack/table-core@8.21.2': {}
'@types/d3-array@3.2.1': {} '@types/d3-array@3.2.1': {}
'@types/d3-color@3.1.3': {} '@types/d3-color@3.1.3': {}
@ -4977,6 +5007,10 @@ snapshots:
dependencies: dependencies:
react: 19.0.0 react: 19.0.0
react-icons@5.5.0(react@19.0.0):
dependencies:
react: 19.0.0
react-is@16.13.1: {} react-is@16.13.1: {}
react-is@18.3.1: {} react-is@18.3.1: {}

View File

@ -1,131 +1,156 @@
export interface Plant { export interface GeoPosition {
UUID: string; lat: number;
Name: string; lng: number;
Variety: string;
AverageHeight: number;
DaysToEmerge: number;
DaysToFlower: number;
DaysToMaturity: number;
EstimateLossRate: number;
EstimateRevenuePerHU: number;
HarvestUnitID: number;
HarvestWindow: number;
IsPerennial: boolean;
LightProfileID: number;
OptimalTemp: number;
PHValue: number;
PlantingDepth: number;
PlantingDetail: string;
RowSpacing: number;
SoilConditionID: number;
WaterNeeds: number;
CreatedAt: string;
UpdatedAt: string;
} }
export interface Crop { export interface GeoMarker {
id: string; type: "marker";
farmId: string; position: GeoPosition;
}
export interface GeoPolygon {
type: "polygon";
path: GeoPosition[];
}
export interface GeoPolyline {
type: "polyline";
path: GeoPosition[];
}
export type GeoFeatureData = GeoMarker | GeoPolygon | GeoPolyline;
export interface Plant {
uuid: string;
name: string; name: string;
plantedDate: Date;
expectedHarvest?: Date;
status: string;
variety?: string; variety?: string;
area?: string; averageHeight?: number;
healthScore?: number; daysToEmerge?: number;
progress?: number; daysToFlower?: number;
daysToMaturity?: number;
estimateLossRate?: number;
estimateRevenuePerHu?: number;
harvestUnitId: number;
harvestWindow?: number;
isPerennial: boolean;
lightProfileId: number;
optimalTemp?: number;
phValue?: number;
plantingDepth?: number;
plantingDetail?: string;
rowSpacing?: number;
soilConditionId: number;
waterNeeds?: number;
createdAt: string;
updatedAt: string;
} }
export interface Cropland { export interface Cropland {
UUID: string; uuid: string;
Name: string; name: string;
Status: string; status: string;
Priority: number; priority: number;
LandSize: number; landSize: number;
GrowthStage: string; growthStage: string;
PlantID: string; plantId: string;
FarmID: string; farmId: string;
CreatedAt: Date; geoFeature?: GeoFeatureData | null;
UpdatedAt: Date; createdAt: Date;
updatedAt: Date;
} }
export interface CropAnalytics { export interface CropAnalytics {
cropId: string; cropId: string;
cropName: string;
farmId: string;
plantName: string;
variety?: string;
currentStatus: string;
growthStage: string;
landSize: number;
lastUpdated: string;
temperature?: number | null;
humidity?: number | null;
soilMoisture?: number | null;
sunlight?: number | null;
windSpeed?: number | null;
rainfall?: number | null;
growthProgress: number; growthProgress: number;
humidity: number; plantHealth?: "good" | "warning" | "critical";
temperature: number; nextAction?: string | null;
sunlight: number; nextActionDue?: string | null;
waterLevel: number; nutrientLevels?: {
plantHealth: "good" | "warning" | "critical"; nitrogen: number | null;
nextAction: string; phosphorus: number | null;
nextActionDue: Date; potassium: number | null;
soilMoisture: number; } | null;
windSpeed: string;
rainfall: string;
nutrientLevels: {
nitrogen: number;
phosphorus: number;
potassium: number;
};
} }
export interface Farm { export interface Farm {
CreatedAt: Date; uuid: string;
FarmType: string; name: string;
Lat: number; farmType: string;
Lon: number; lat: number;
Name: string; lon: number;
OwnerID: string; ownerId: string;
TotalSize: string; totalSize: string;
UUID: string; createdAt: Date;
UpdatedAt: Date; updatedAt: Date;
Crops: Cropland[]; crops: Cropland[];
} }
// export interface Farm {
// id: string;
// name: string;
// location: string;
// type: string;
// createdAt: Date;
// area?: string;
// crops: number;
// weather?: {
// temperature: number;
// humidity: number;
// rainfall: string;
// sunlight: number;
// };
// }
export interface User { export interface User {
ID: number; id: number;
UUID: string; uuid: string;
Username: string; username?: string;
Password: string; password?: string;
Email: string; email: string;
CreatedAt: string; createdAt: string;
UpdatedAt: string; updatedAt: string;
Avatar: string; avatar?: string;
IsActive: boolean; isActive: boolean;
} }
export type InventoryItem = { export interface InventoryItem {
id: number; id: string;
userId: string;
name: string; name: string;
category: string; categoryId: number;
type: string; category: { id: number; name: string };
quantity: number; quantity: number;
unit: string; unitId: number;
lastUpdated: string; unit: { id: number; name: string };
status: string; dateAdded: string;
}; statusId: number;
export type InventoryItemStatus = { status: { id: number; name: string };
createdAt: string;
updatedAt: string;
}
export interface InventoryStatus {
id: number; id: number;
name: string; name: string;
}; }
export type CreateInventoryItemInput = Omit<InventoryItem, "id" | "lastUpdated" | "status">; export interface InventoryCategory {
id: number;
name: string;
}
export interface HarvestUnit {
id: number;
name: string;
}
export interface CreateInventoryItemInput {
name: string;
categoryId: number;
quantity: number;
unitId: number;
dateAdded: string;
statusId: number;
}
export type UpdateInventoryItemInput = Partial<CreateInventoryItemInput> & { id: string };
export interface Blog { export interface Blog {
id: number; id: number;
@ -152,8 +177,6 @@ export interface Blog {
}[]; }[];
} }
// ----------- Maps -----------$
export type OverlayGeometry = export type OverlayGeometry =
| google.maps.Marker | google.maps.Marker
| google.maps.Polygon | google.maps.Polygon