mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-18 21:44:08 +01:00
303 lines
13 KiB
TypeScript
303 lines
13 KiB
TypeScript
// crop-dialog.tsx
|
|
"use client";
|
|
|
|
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
|
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 { Card, CardContent } from "@/components/ui/card";
|
|
import {
|
|
Check,
|
|
Sprout,
|
|
AlertTriangle,
|
|
Loader2,
|
|
CalendarDays,
|
|
Thermometer,
|
|
Droplets,
|
|
MapPin,
|
|
Maximize,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
// Import the updated/new types
|
|
import type { Cropland, GeoFeatureData, GeoPosition } from "@/types";
|
|
import { PlantResponse } from "@/api/plant";
|
|
import { getPlants } from "@/api/plant";
|
|
// Import the map component and the ShapeData type (ensure ShapeData in types.ts matches this)
|
|
import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing";
|
|
|
|
interface CropDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSubmit: (data: Partial<Cropland>) => Promise<void>;
|
|
isSubmitting: boolean;
|
|
}
|
|
|
|
export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropDialogProps) {
|
|
// --- State ---
|
|
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 () => {
|
|
// 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;
|
|
}
|
|
|
|
const cropData: Partial<Cropland> = {
|
|
// Default name, consider making this editable
|
|
name: `${selectedPlant.name} Field ${Math.floor(100 + Math.random() * 900)}`,
|
|
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
|
|
};
|
|
|
|
console.log("Submitting Cropland Data:", cropData);
|
|
|
|
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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<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">
|
|
<DialogHeader className="p-6 pb-0">
|
|
<DialogTitle className="text-xl font-semibold">Create New Cropland</DialogTitle>
|
|
<DialogDescription>
|
|
Select a plant and draw the cropland boundary or mark its location on the map.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-grow grid md:grid-cols-12 gap-0 overflow-hidden">
|
|
{/* Left Side: Plant Selection */}
|
|
<div className="md:col-span-4 lg:col-span-3 p-6 pt-2 border-r dark:border-slate-700 overflow-y-auto">
|
|
<h3 className="text-md font-medium mb-4 sticky top-0 bg-background py-2">1. Select Plant</h3>
|
|
{/* 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>
|
|
)}
|
|
{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>
|
|
|
|
{/* Right Side: Map */}
|
|
<div className="md:col-span-8 lg:col-span-9 p-6 pt-2 flex flex-col overflow-hidden">
|
|
<h3 className="text-md font-medium mb-4">2. Define Boundary / Location</h3>
|
|
<div className="flex-grow bg-muted/30 dark:bg-muted/20 rounded-md border dark:border-slate-700 overflow-hidden relative">
|
|
<GoogleMapWithDrawing onShapeDrawn={handleShapeDrawn} />
|
|
|
|
{/* Display feedback based on drawn shape */}
|
|
{geoFeature?.type === "polygon" && calculatedArea !== null && (
|
|
<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>
|
|
<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>
|
|
|
|
{/* 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>
|
|
</Dialog>
|
|
);
|
|
}
|