diff --git a/backend/internal/api/crop.go b/backend/internal/api/crop.go index 477c360..fe4cd3f 100644 --- a/backend/internal/api/crop.go +++ b/backend/internal/api/crop.go @@ -15,10 +15,8 @@ import ( func (a *api) registerCropRoutes(_ chi.Router, api huma.API) { tags := []string{"crop"} - prefix := "/crop" - // Register GET /crop huma.Register(api, huma.Operation{ OperationID: "getAllCroplands", Method: http.MethodGet, @@ -26,7 +24,6 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) { Tags: tags, }, a.getAllCroplandsHandler) - // Register GET /crop/{uuid} huma.Register(api, huma.Operation{ OperationID: "getCroplandByID", Method: http.MethodGet, @@ -34,7 +31,6 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) { Tags: tags, }, a.getCroplandByIDHandler) - // Register GET /crop/farm/{farm_id} huma.Register(api, huma.Operation{ OperationID: "getAllCroplandsByFarmID", Method: http.MethodGet, @@ -42,15 +38,23 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) { Tags: tags, }, a.getAllCroplandsByFarmIDHandler) - // Register POST /crop (Create or Update) huma.Register(api, huma.Operation{ - OperationID: "createOrUpdateCropland", + OperationID: "createCropland", Method: http.MethodPost, Path: prefix, Tags: tags, - }, a.createOrUpdateCroplandHandler) + }, a.createCroplandHandler) + + huma.Register(api, huma.Operation{ + OperationID: "updateCropland", + Method: http.MethodPut, + Path: prefix + "/{uuid}", + Tags: tags, + }, a.updateCroplandHandler) } +// --- Common Output Structs --- + type GetCroplandsOutput struct { Body struct { Croplands []domain.Cropland `json:"croplands"` @@ -63,22 +67,45 @@ type GetCroplandByIDOutput struct { } } -type CreateOrUpdateCroplandInput struct { +// --- Create Structs --- + +type CreateCroplandInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` Body struct { - UUID string `json:"uuid,omitempty"` - Name string `json:"name"` - Status string `json:"status"` + Name string `json:"name" required:"true"` + Status string `json:"status" required:"true"` Priority int `json:"priority"` LandSize float64 `json:"landSize"` - GrowthStage string `json:"growthStage"` - PlantID string `json:"plantId"` - FarmID string `json:"farmId"` + GrowthStage string `json:"growthStage" required:"true"` + PlantID string `json:"plantId" required:"true" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"` + FarmID string `json:"farmId" required:"true" example:"b2c3d4e5-f6a7-8901-2345-67890abcdef0"` GeoFeature json.RawMessage `json:"geoFeature,omitempty"` } } -type CreateOrUpdateCroplandOutput struct { +type CreateCroplandOutput struct { + Body struct { + Cropland domain.Cropland `json:"cropland"` + } +} + +// --- Update Structs --- + +type UpdateCroplandInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + UUID string `path:"uuid" required:"true" example:"c3d4e5f6-a7b8-9012-3456-7890abcdef01"` + Body struct { + Name string `json:"name" required:"true"` + Status string `json:"status" required:"true"` + Priority int `json:"priority"` + LandSize float64 `json:"landSize"` + GrowthStage string `json:"growthStage" required:"true"` + PlantID string `json:"plantId" required:"true" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"` + GeoFeature json.RawMessage `json:"geoFeature,omitempty"` + } +} + +type UpdateCroplandOutput struct { Body struct { Cropland domain.Cropland `json:"cropland"` } @@ -90,8 +117,7 @@ func (a *api) getAllCroplandsHandler(ctx context.Context, input *struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` }) (*GetCroplandsOutput, error) { // Note: This currently fetches ALL croplands. Might need owner filtering later. - // For now, ensure authentication happens. - _, err := a.getUserIDFromHeader(input.Header) // Verify token + _, err := a.getUserIDFromHeader(input.Header) if err != nil { return nil, huma.Error401Unauthorized("Authentication failed", err) } @@ -112,7 +138,7 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` }) (*GetCroplandByIDOutput, error) { - userID, err := a.getUserIDFromHeader(input.Header) // Verify token and get user ID + userID, err := a.getUserIDFromHeader(input.Header) if err != nil { return nil, huma.Error401Unauthorized("Authentication failed", err) } @@ -120,15 +146,15 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct { resp := &GetCroplandByIDOutput{} if input.UUID == "" { - return nil, huma.Error400BadRequest("UUID parameter is required") + return nil, huma.Error400BadRequest("UUID path parameter is required") } - _, err = uuid.FromString(input.UUID) + croplandUUID, err := uuid.FromString(input.UUID) if err != nil { return nil, huma.Error400BadRequest("Invalid UUID format") } - cropland, err := a.cropRepo.GetByID(ctx, input.UUID) + cropland, err := a.cropRepo.GetByID(ctx, croplandUUID.String()) if err != nil { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { a.logger.Warn("Cropland not found", "croplandId", input.UUID, "requestingUserId", userID) @@ -138,12 +164,10 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct { return nil, huma.Error500InternalServerError("Failed to retrieve cropland") } - // Authorization check: User must own the farm this cropland belongs to - farm, err := a.farmRepo.GetByID(ctx, cropland.FarmID) // Fetch the farm + farm, err := a.farmRepo.GetByID(ctx, cropland.FarmID) if err != nil { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { a.logger.Error("Farm associated with cropland not found", "farmId", cropland.FarmID, "croplandId", input.UUID) - // This indicates a data integrity issue if the cropland exists but farm doesn't return nil, huma.Error404NotFound("Associated farm not found for cropland") } a.logger.Error("Failed to fetch farm for cropland authorization", "farmId", cropland.FarmID, "error", err) @@ -171,7 +195,7 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct resp := &GetCroplandsOutput{} if input.FarmID == "" { - return nil, huma.Error400BadRequest("farm_id parameter is required") + return nil, huma.Error400BadRequest("farmId path parameter is required") } farmUUID, err := uuid.FromString(input.FarmID) @@ -179,7 +203,6 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct return nil, huma.Error400BadRequest("Invalid farmId format") } - // Authorization check: User must own the farm they are requesting crops for farm, err := a.farmRepo.GetByID(ctx, farmUUID.String()) if err != nil { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { @@ -208,83 +231,41 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct return resp, nil } -func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOrUpdateCroplandInput) (*CreateOrUpdateCroplandOutput, error) { +func (a *api) createCroplandHandler(ctx context.Context, input *CreateCroplandInput) (*CreateCroplandOutput, error) { userID, err := a.getUserIDFromHeader(input.Header) if err != nil { return nil, huma.Error401Unauthorized("Authentication failed", err) } - resp := &CreateOrUpdateCroplandOutput{} + resp := &CreateCroplandOutput{} - // --- Input Validation --- - if input.Body.Name == "" { - return nil, huma.Error400BadRequest("name is required") - } - if input.Body.Status == "" { - return nil, huma.Error400BadRequest("status is required") - } - if input.Body.GrowthStage == "" { - return nil, huma.Error400BadRequest("growthStage is required") - } - if input.Body.PlantID == "" { - return nil, huma.Error400BadRequest("plantId is required") - } - if input.Body.FarmID == "" { - return nil, huma.Error400BadRequest("farmId is required") - } - - // Validate UUID formats - if input.Body.UUID != "" { - if _, err := uuid.FromString(input.Body.UUID); err != nil { - return nil, huma.Error400BadRequest("invalid cropland UUID format") - } - } if _, err := uuid.FromString(input.Body.PlantID); err != nil { return nil, huma.Error400BadRequest("invalid plantId UUID format") } farmUUID, err := uuid.FromString(input.Body.FarmID) if err != nil { - return nil, huma.Error400BadRequest("invalid farm_id UUID format") + return nil, huma.Error400BadRequest("invalid farmId UUID format") } - // Validate JSON format if GeoFeature is provided if input.Body.GeoFeature != nil && !json.Valid(input.Body.GeoFeature) { return nil, huma.Error400BadRequest("invalid JSON format for geoFeature") } - // --- Authorization Check --- - // User must own the farm they are adding/updating a crop for farm, err := a.farmRepo.GetByID(ctx, farmUUID.String()) if err != nil { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { - a.logger.Warn("Attempt to create/update crop for non-existent farm", "farmId", input.Body.FarmID, "requestingUserId", userID) + a.logger.Warn("Attempt to create crop for non-existent farm", "farmId", input.Body.FarmID, "requestingUserId", userID) return nil, huma.Error404NotFound("Target farm not found") } - a.logger.Error("Failed to fetch farm for create/update cropland authorization", "farmId", input.Body.FarmID, "error", err) + a.logger.Error("Failed to fetch farm for create cropland authorization", "farmId", input.Body.FarmID, "error", err) return nil, huma.Error500InternalServerError("Failed to verify ownership") } if farm.OwnerID != userID { - a.logger.Warn("Unauthorized attempt to create/update crop on farm", "farmId", input.Body.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID) - return nil, huma.Error403Forbidden("You are not authorized to modify crops on this farm") + a.logger.Warn("Unauthorized attempt to create crop on farm", "farmId", input.Body.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID) + return nil, huma.Error403Forbidden("You are not authorized to add crops to this farm") } - // If updating, ensure the user also owns the existing cropland (redundant if farm check passes, but good practice) - if input.Body.UUID != "" { - existingCrop, err := a.cropRepo.GetByID(ctx, input.Body.UUID) - if err != nil && !errors.Is(err, domain.ErrNotFound) && !errors.Is(err, sql.ErrNoRows) { // Ignore not found for creation - a.logger.Error("Failed to get existing cropland for update authorization check", "croplandId", input.Body.UUID, "error", err) - return nil, huma.Error500InternalServerError("Failed to verify existing cropland") - } - // If cropland exists and its FarmID doesn't match the input/authorized FarmID, deny. - if err == nil && existingCrop.FarmID != farmUUID.String() { - a.logger.Warn("Attempt to update cropland belonging to a different farm", "croplandId", input.Body.UUID, "inputFarmId", input.Body.FarmID, "actualFarmId", existingCrop.FarmID) - return nil, huma.Error403Forbidden("Cropland does not belong to the specified farm") - } - } - - // --- Prepare and Save Cropland --- cropland := &domain.Cropland{ - UUID: input.Body.UUID, Name: input.Body.Name, Status: input.Body.Status, Priority: input.Body.Priority, @@ -295,15 +276,84 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr GeoFeature: input.Body.GeoFeature, } - // Use the repository's CreateOrUpdate which handles assigning UUID if needed err = a.cropRepo.CreateOrUpdate(ctx, cropland) if err != nil { - a.logger.Error("Failed to save cropland to database", "farm_id", input.Body.FarmID, "plantId", input.Body.PlantID, "error", err) + a.logger.Error("Failed to create cropland in database", "farmId", input.Body.FarmID, "plantId", input.Body.PlantID, "error", err) return nil, huma.Error500InternalServerError("Failed to save cropland") } - a.logger.Info("Cropland created/updated successfully", "croplandId", cropland.UUID, "farmId", cropland.FarmID) + a.logger.Info("Cropland created successfully", "croplandId", cropland.UUID, "farmId", cropland.FarmID) resp.Body.Cropland = *cropland return resp, nil } + +func (a *api) updateCroplandHandler(ctx context.Context, input *UpdateCroplandInput) (*UpdateCroplandOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed", err) + } + + resp := &UpdateCroplandOutput{} + + croplandUUID, err := uuid.FromString(input.UUID) + if err != nil { + return nil, huma.Error400BadRequest("Invalid cropland UUID format in path") + } + + if _, err := uuid.FromString(input.Body.PlantID); err != nil { + return nil, huma.Error400BadRequest("invalid plantId UUID format in body") + } + + if input.Body.GeoFeature != nil && !json.Valid(input.Body.GeoFeature) { + return nil, huma.Error400BadRequest("invalid JSON format for geoFeature") + } + + existingCrop, err := a.cropRepo.GetByID(ctx, croplandUUID.String()) + if err != nil { + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { + a.logger.Warn("Attempt to update non-existent cropland", "croplandId", input.UUID, "requestingUserId", userID) + return nil, huma.Error404NotFound("Cropland not found") + } + a.logger.Error("Failed to get existing cropland for update", "croplandId", input.UUID, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve cropland for update") + } + + farm, err := a.farmRepo.GetByID(ctx, existingCrop.FarmID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { + a.logger.Error("Farm associated with existing cropland not found during update", "farmId", existingCrop.FarmID, "croplandId", input.UUID) + return nil, huma.Error500InternalServerError("Associated farm data inconsistent") + } + a.logger.Error("Failed to fetch farm for update cropland authorization", "farmId", existingCrop.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to verify ownership for update") + } + if farm.OwnerID != userID { + a.logger.Warn("Unauthorized attempt to update crop on farm", "croplandId", input.UUID, "farmId", existingCrop.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID) + return nil, huma.Error403Forbidden("You are not authorized to modify this cropland") + } + + updatedCropland := &domain.Cropland{ + UUID: existingCrop.UUID, + FarmID: existingCrop.FarmID, + Name: input.Body.Name, + Status: input.Body.Status, + Priority: input.Body.Priority, + LandSize: input.Body.LandSize, + GrowthStage: input.Body.GrowthStage, + PlantID: input.Body.PlantID, + GeoFeature: input.Body.GeoFeature, + CreatedAt: existingCrop.CreatedAt, + } + + err = a.cropRepo.CreateOrUpdate(ctx, updatedCropland) + if err != nil { + a.logger.Error("Failed to update cropland in database", "croplandId", updatedCropland.UUID, "error", err) + return nil, huma.Error500InternalServerError("Failed to update cropland") + } + + a.logger.Info("Cropland updated successfully", "croplandId", updatedCropland.UUID, "farmId", updatedCropland.FarmID) + + resp.Body.Cropland = *updatedCropland + return resp, nil +} diff --git a/frontend/api/crop.ts b/frontend/api/crop.ts index a8eafe8..35cb357 100644 --- a/frontend/api/crop.ts +++ b/frontend/api/crop.ts @@ -1,5 +1,5 @@ +// frontend/api/crop.ts import axiosInstance from "./config"; -// Use refactored types import type { Cropland, CropAnalytics } from "@/types"; export interface CropResponse { @@ -7,30 +7,72 @@ export interface CropResponse { } /** - * Fetch all Croplands for a specific FarmID. Returns CropResponse. + * Fetch all Croplands for a specific FarmID. */ export async function getCropsByFarmId(farmId: string): Promise { - // Assuming backend returns { "croplands": [...] } - return axiosInstance.get(`/crop/farm/${farmId}`).then((res) => res.data); + return axiosInstance.get<{ croplands: Cropland[] }>(`/crop/farm/${farmId}`).then((res) => res.data); } /** - * Fetch a specific Cropland by its ID. Returns Cropland. + * Fetch a specific Cropland by its ID. */ export async function getCropById(cropId: string): Promise { - // Assuming backend returns { "cropland": ... } - return axiosInstance.get(`/crop/${cropId}`).then((res) => res.data); - // If backend returns object directly: return axiosInstance.get(`/crop/${cropId}`).then((res) => res.data); + const response = await axiosInstance.get<{ cropland: Cropland }>(`/crop/${cropId}`); + return response.data.cropland; } /** - * Create a new crop (Cropland). Sends camelCase data matching backend tags. Returns Cropland. + * Create a new crop (Cropland). */ -export async function createCrop(data: Partial>): Promise { +export async function createCrop(data: { + name: string; + status: string; + priority?: number; + landSize?: number; + growthStage: string; + plantId: string; + farmId: string; + geoFeature?: unknown | null; +}): Promise { if (!data.farmId) { throw new Error("farmId is required to create a crop."); } - // Payload uses camelCase keys matching backend JSON tags + + const payload = { + name: data.name, + status: data.status, + priority: data.priority ?? 0, + landSize: data.landSize ?? 0, + growthStage: data.growthStage, + plantId: data.plantId, + farmId: data.farmId, + geoFeature: data.geoFeature, + }; + + const response = await axiosInstance.post<{ cropland: Cropland }>(`/crop`, payload); + return response.data.cropland; +} + +/** + * Update an existing cropland by its ID. + * Note: farmId cannot be changed via this endpoint + */ +export async function updateCrop( + cropId: string, + data: { + name: string; + status: string; + priority: number; + landSize: number; + growthStage: string; + plantId: string; + geoFeature: unknown | null; + } +): Promise { + if (!cropId) { + throw new Error("cropId is required to update a crop."); + } + const payload = { name: data.name, status: data.status, @@ -38,17 +80,36 @@ export async function createCrop(data: Partial(`/crop`, payload).then((res) => res.data.cropland); // Assuming backend wraps in { "cropland": ... } - // If backend returns object directly: return axiosInstance.post(`/crop`, payload).then((res) => res.data); + + const response = await axiosInstance.put<{ cropland: Cropland }>(`/crop/${cropId}`, payload); + return response.data.cropland; } /** - * Fetch analytics data for a specific crop by its ID. Returns CropAnalytics. + * Delete a specific cropland by its ID. */ -export async function fetchCropAnalytics(cropId: string): Promise { - // Assuming backend returns { body: { ... } } structure from Huma - return axiosInstance.get(`/analytics/crop/${cropId}`).then((res) => res.data); +export async function deleteCrop(cropId: string): Promise<{ message: string } | void> { + const response = await axiosInstance.delete(`/crop/${cropId}`); + if (response.status === 204) { + return; + } + return response.data as { message: string }; +} + +/** + * Fetch analytics data for a specific crop by its ID. + */ +export async function fetchCropAnalytics(cropId: string): Promise { + try { + const response = await axiosInstance.get(`/analytics/crop/${cropId}`); + return response.data; + } catch (error: any) { + console.error("Error fetching crop analytics:", error); + if (error.response?.status === 404) { + return null; + } + throw error; + } } diff --git a/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx b/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx index bf1d5b6..2bf491c 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx @@ -36,16 +36,17 @@ import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-wi interface CropDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onSubmit: (data: Partial) => Promise; + onSubmit: (data: Partial>) => Promise; isSubmitting: boolean; + initialData?: Cropland | null; + isEditing?: boolean; } -export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropDialogProps) { +export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting, initialData, isEditing }: CropDialogProps) { // --- State --- const [selectedPlantUUID, setSelectedPlantUUID] = useState(null); - // State to hold the structured GeoFeature data const [geoFeature, setGeoFeature] = useState(null); - const [calculatedArea, setCalculatedArea] = useState(null); // Keep for display + const [calculatedArea, setCalculatedArea] = useState(null); // --- Load Google Maps Geometry Library --- const geometryLib = useMapsLibrary("geometry"); @@ -63,6 +64,7 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD refetchOnWindowFocus: false, }); const plants = useMemo(() => plantData?.plants || [], [plantData]); + const selectedPlant = useMemo(() => { return plants.find((p) => p.uuid === selectedPlantUUID); }, [plants, selectedPlantUUID]); @@ -71,10 +73,14 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD useEffect(() => { if (!open) { setSelectedPlantUUID(null); - setGeoFeature(null); // Reset geoFeature state + setGeoFeature(null); setCalculatedArea(null); + } else if (initialData) { + setSelectedPlantUUID(initialData.plantId); + setGeoFeature(initialData.geoFeature ?? null); + setCalculatedArea(initialData.landSize ?? null); } - }, [open]); + }, [open, initialData]); // --- Map Interaction Handler --- const handleShapeDrawn = useCallback( @@ -169,9 +175,13 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD - Create New Cropland + + {isEditing ? "Edit Cropland" : "Create New Cropland"} + - Select a plant and draw the cropland boundary or mark its location on the map. + {isEditing + ? "Update the cropland details and location." + : "Select a plant and draw the cropland boundary or mark its location on the map."} diff --git a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx index 461928b..92a75a9 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react"; import { useRouter, useParams } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, LineChart, @@ -11,7 +11,6 @@ import { Sun, ThermometerSun, Timer, - ListCollapse, Leaf, CloudRain, Wind, @@ -22,6 +21,7 @@ import { LeafIcon, History, Bot, + MoreHorizontal, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -35,16 +35,42 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import type { Cropland, CropAnalytics, Farm } from "@/types"; import { getFarm } from "@/api/farm"; import { getPlants, PlantResponse } from "@/api/plant"; -import { getCropById, fetchCropAnalytics } from "@/api/crop"; +// Import the updated API functions +import { getCropById, fetchCropAnalytics, deleteCrop, updateCrop } from "@/api/crop"; import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; +import { toast } from "sonner"; +import { CropDialog } from "../../crop-dialog"; // Assuming CropDialog is in the parent directory +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; + +// Define the expected shape of data coming from CropDialog for update +// Excludes fields not sent in the PUT request body (uuid, farmId, createdAt, updatedAt) +type CropUpdateData = Omit; export default function CropDetailPage() { const router = useRouter(); const params = useParams<{ farmId: string; cropId: string }>(); const { farmId, cropId } = params; + const queryClient = useQueryClient(); const [isChatOpen, setIsChatOpen] = useState(false); const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); + const [isEditCropOpen, setIsEditCropOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); // --- Fetch Farm Data --- const { data: farm, isLoading: isLoadingFarm } = useQuery({ @@ -64,7 +90,7 @@ export default function CropDetailPage() { queryKey: ["crop", cropId], queryFn: () => getCropById(cropId), enabled: !!cropId, - staleTime: 60 * 1000, + staleTime: 60 * 1000, // Refetch more often than farm/plants }); // --- Fetch All Plants Data --- @@ -76,7 +102,7 @@ export default function CropDetailPage() { } = useQuery({ queryKey: ["plants"], queryFn: getPlants, - staleTime: 1000 * 60 * 60, + staleTime: 1000 * 60 * 60, // Plants data is relatively static refetchOnWindowFocus: false, }); @@ -88,7 +114,7 @@ export default function CropDetailPage() { // --- Fetch Crop Analytics Data --- const { - data: analytics, // Type is CropAnalytics | null + data: analytics, isLoading: isLoadingAnalytics, isError: isErrorAnalytics, error: errorAnalytics, @@ -99,9 +125,66 @@ export default function CropDetailPage() { staleTime: 5 * 60 * 1000, }); + // --- Delete Crop Mutation --- + const deleteMutation = useMutation({ + mutationFn: () => deleteCrop(cropId), // Uses DELETE /crop/{cropId} + onSuccess: () => { + toast.success(`Crop "${cropland?.name}" deleted successfully.`); + queryClient.invalidateQueries({ queryKey: ["crops", farmId] }); + queryClient.invalidateQueries({ queryKey: ["farm", farmId] }); + queryClient.removeQueries({ queryKey: ["crop", cropId] }); + queryClient.removeQueries({ queryKey: ["cropAnalytics", cropId] }); + router.push(`/farms/${farmId}`); + }, + onError: (error) => { + console.error("Failed to delete crop:", error); + toast.error(`Failed to delete crop: ${(error as Error).message}`); + }, + onSettled: () => { + setIsDeleteDialogOpen(false); + }, + }); + + // --- Update Crop Mutation --- + // Updated to use the new updateCrop signature: updateCrop(cropId, payload) + const updateMutation = useMutation({ + // dataFromDialog should contain the fields needed for the PUT request body + mutationFn: async (dataFromDialog: CropUpdateData) => { + if (!cropId) { + throw new Error("Crop ID is missing for update."); + } + // Prepare the payload matching the UpdateCroplandInput body structure + // Ensure all required fields for the PUT endpoint are present + const updatePayload = { + name: dataFromDialog.name, + status: dataFromDialog.status, + priority: dataFromDialog.priority ?? 0, // Use default or ensure it comes from dialog + landSize: dataFromDialog.landSize ?? 0, // Use default or ensure it comes from dialog + growthStage: dataFromDialog.growthStage, + plantId: dataFromDialog.plantId, + geoFeature: dataFromDialog.geoFeature, + }; + // Call the API function with cropId and the prepared payload + return updateCrop(cropId, updatePayload); + }, + onSuccess: (updatedCrop) => { + toast.success(`Crop "${updatedCrop.name}" updated successfully.`); + // Invalidate queries to refetch data + queryClient.invalidateQueries({ queryKey: ["crop", cropId] }); + queryClient.invalidateQueries({ queryKey: ["crops", farmId] }); // Update list on farm page if name changed + queryClient.invalidateQueries({ queryKey: ["farm", farmId] }); // Update farm details if needed + queryClient.invalidateQueries({ queryKey: ["cropAnalytics", cropId] }); + setIsEditCropOpen(false); // Close the edit dialog + }, + onError: (error) => { + console.error("Failed to update crop:", error); + toast.error(`Failed to update crop: ${(error as Error).message}`); + }, + }); + // --- Combined Loading and Error States --- const isLoading = isLoadingFarm || isLoadingCropland || isLoadingPlants || isLoadingAnalytics; - const isError = isErrorCropland || isErrorPlants || isErrorAnalytics; // Prioritize crop/analytics errors + const isError = isErrorCropland || isErrorPlants || isErrorAnalytics; const error = errorCropland || errorPlants || errorAnalytics; // --- Loading State --- @@ -117,6 +200,9 @@ export default function CropDetailPage() { // --- Error State --- if (isError || !cropland) { console.error("Error loading crop details:", error); + const errorMessage = isErrorCropland + ? `Crop with ID ${cropId} not found or could not be loaded.` + : (error as Error)?.message || "An unexpected error occurred."; return (
); @@ -144,8 +226,11 @@ export default function CropDetailPage() { 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 border-yellow-200", critical: "text-red-500 bg-red-50 dark:bg-red-900 border-red-200", + unknown: "text-gray-500 bg-gray-50 dark:bg-gray-900 border-gray-200", // Added for safety }; - const healthStatus = analytics?.plantHealth || "good"; + // Use a safe default if analytics or plantHealth is missing + const healthStatus = (analytics?.plantHealth as keyof typeof healthColors) || "unknown"; + const healthColorClass = healthColors[healthStatus] || healthColors.unknown; const quickActions = [ { @@ -154,6 +239,7 @@ export default function CropDetailPage() { description: "View detailed growth analytics", onClick: () => setIsAnalyticsOpen(true), color: "bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300", + disabled: !analytics, // Disable if no analytics data }, { title: "Chat Assistant", @@ -162,35 +248,24 @@ export default function CropDetailPage() { onClick: () => setIsChatOpen(true), color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300", }, - { - title: "Crop Details", - icon: ListCollapse, - description: "View detailed information", - onClick: () => console.log("Details clicked - Placeholder"), - color: "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300", - }, - { - title: "Settings", - icon: Settings, - description: "Configure crop settings", - onClick: () => console.log("Settings clicked - Placeholder"), - color: "bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-300", - }, + // Settings moved to dropdown ]; const plantedDate = cropland.createdAt ? new Date(cropland.createdAt) : null; - const daysToMaturity = plant?.daysToMaturity; // Use camelCase + const daysToMaturity = plant?.daysToMaturity; const expectedHarvestDate = - plantedDate && daysToMaturity ? new Date(plantedDate.getTime() + daysToMaturity * 24 * 60 * 60 * 1000) : null; + plantedDate && typeof daysToMaturity === "number" + ? 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 + const growthProgress = analytics?.growthProgress ?? 0; + const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize.toFixed(2)} ha` : "N/A"; return (
{/* Breadcrumbs */} -
-

{cropland.name}

{/* Use camelCase */} +

{cropland.name}

- {plant?.variety || "Unknown Variety"} • {displayArea} {/* Use camelCase */} + {plant?.variety || "Unknown Variety"} • {displayArea}

- - {cropland.status} {/* Use camelCase */} + + {cropland.status}
{expectedHarvestDate ? ( @@ -260,23 +357,28 @@ export default function CropDetailPage() { {/* Left Column */}
{/* Quick Actions */} -
+
{quickActions.map((action) => ( ))} @@ -286,51 +388,70 @@ export default function CropDetailPage() { Environmental Conditions - Real-time monitoring data + Real-time monitoring data (if available)
{[ + // ... (metric definitions remain the same) { icon: ThermometerSun, label: "Temperature", - value: analytics?.temperature ? `${analytics.temperature}°C` : "N/A", + value: + analytics?.temperature !== null && analytics?.temperature !== undefined + ? `${analytics.temperature.toFixed(1)}°C` + : "N/A", color: "text-orange-500 dark:text-orange-300", bg: "bg-orange-50 dark:bg-orange-900", }, { icon: Droplets, label: "Humidity", - value: analytics?.humidity ? `${analytics.humidity}%` : "N/A", + value: + analytics?.humidity !== null && analytics?.humidity !== undefined + ? `${analytics.humidity.toFixed(0)}%` + : "N/A", color: "text-blue-500 dark:text-blue-300", bg: "bg-blue-50 dark:bg-blue-900", }, { icon: Sun, label: "Sunlight", - value: analytics?.sunlight ? `${analytics.sunlight}%` : "N/A", + value: + analytics?.sunlight !== null && analytics?.sunlight !== undefined + ? `${analytics.sunlight.toFixed(0)}%` + : "N/A", color: "text-yellow-500 dark:text-yellow-300", bg: "bg-yellow-50 dark:bg-yellow-900", }, { icon: Leaf, label: "Soil Moisture", - value: analytics?.soilMoisture ? `${analytics.soilMoisture}%` : "N/A", + value: + analytics?.soilMoisture !== null && analytics?.soilMoisture !== undefined + ? `${analytics.soilMoisture.toFixed(0)}%` + : "N/A", color: "text-green-500 dark:text-green-300", bg: "bg-green-50 dark:bg-green-900", }, { icon: Wind, label: "Wind Speed", - value: analytics?.windSpeed ?? "N/A", + value: + analytics?.windSpeed !== null && analytics?.windSpeed !== undefined + ? `${analytics.windSpeed.toFixed(1)} m/s` + : "N/A", color: "text-gray-500 dark:text-gray-300", bg: "bg-gray-50 dark:bg-gray-900", }, { icon: CloudRain, - label: "Rainfall", - value: analytics?.rainfall ?? "N/A", + label: "Rainfall (1h)", + value: + analytics?.rainfall !== null && analytics?.rainfall !== undefined + ? `${analytics.rainfall.toFixed(1)} mm` + : "N/A", color: "text-indigo-500 dark:text-indigo-300", bg: "bg-indigo-50 dark:bg-indigo-900", }, @@ -350,42 +471,50 @@ export default function CropDetailPage() { ))}
- - {/* Growth Progress */} -
-
- Growth Progress - {growthProgress}% -
- -

- Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity. -

-
- {/* Next Action Card */} - - -
-
- -
-
-

Next Action Required

-

- {analytics?.nextAction || "Check crop status"} -

- {analytics?.nextActionDue && ( -

- Due by {new Date(analytics.nextActionDue).toLocaleDateString()} -

- )} - {!analytics?.nextAction && ( -

No immediate actions required.

- )} + {/* Show message if no analytics at all */} + {!analytics && !isLoadingAnalytics && ( +

Environmental data not available.

+ )} + {analytics && ( + <> + + {/* Growth Progress */} +
+
+ Growth Progress + {growthProgress}%
+ +

+ Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity. +

- - + {/* Next Action Card */} + + +
+
+ +
+
+

Next Action Required

+

+ {analytics?.nextAction || "Check crop status"} +

+ {analytics?.nextActionDue && ( +

+ Due by {new Date(analytics.nextActionDue).toLocaleDateString()} +

+ )} + {!analytics?.nextAction && ( +

No immediate actions required.

+ )} +
+
+
+
+ + )}
@@ -397,11 +526,11 @@ export default function CropDetailPage() { Visual representation on the farm - {/* TODO: Ensure GoogleMapWithDrawing correctly parses and displays GeoFeatureData */} @@ -420,37 +549,39 @@ export default function CropDetailPage() {
- {[ - { - name: "Nitrogen (N)", - value: analytics?.nutrientLevels?.nitrogen, - color: "bg-blue-500 dark:bg-blue-700", - }, - { - name: "Phosphorus (P)", - value: analytics?.nutrientLevels?.phosphorus, - color: "bg-yellow-500 dark:bg-yellow-700", - }, - { - name: "Potassium (K)", - value: analytics?.nutrientLevels?.potassium, - color: "bg-green-500 dark:bg-green-700", - }, - ].map((nutrient) => ( -
-
- {nutrient.name} - {nutrient.value ?? "N/A"}% + {/* Check if analytics and nutrientLevels exist before mapping */} + {analytics?.nutrientLevels ? ( + [ + { + name: "Nitrogen (N)", + value: analytics.nutrientLevels.nitrogen, + color: "bg-blue-500 dark:bg-blue-700", + }, + { + name: "Phosphorus (P)", + value: analytics.nutrientLevels.phosphorus, + color: "bg-yellow-500 dark:bg-yellow-700", + }, + { + name: "Potassium (K)", + value: analytics.nutrientLevels.potassium, + color: "bg-green-500 dark:bg-green-700", + }, + ].map((nutrient) => ( +
+
+ {nutrient.name} + {nutrient.value ?? "N/A"}% +
+
- -
- ))} - {!analytics?.nutrientLevels && ( + )) + ) : (

Nutrient data not available.

)}
@@ -467,6 +598,7 @@ export default function CropDetailPage() { + {/* Placeholder - Replace with actual activity log */}
No recent activity logged.
@@ -476,28 +608,52 @@ export default function CropDetailPage() { {/* Dialogs */} - {/* Ensure AnalyticsDialog uses the correct props */} + + {/* Conditionally render AnalyticsDialog only if analytics data exists */} {analytics && ( )} + + {/* Edit Crop Dialog */} + { + // 'data' from the dialog should match CropUpdateData structure + await updateMutation.mutateAsync(data as CropUpdateData); + }} + isSubmitting={updateMutation.isPending} + isEditing={true} // Indicate that this is for editing + /> + + {/* Delete Confirmation Dialog */} + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the crop "{cropland.name}" and all + associated data. + + + + Cancel + deleteMutation.mutate()} + disabled={deleteMutation.isPending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + {deleteMutation.isPending && } + Delete Crop + + + +
); diff --git a/frontend/app/(sidebar)/farms/edit-farm-form.tsx b/frontend/app/(sidebar)/farms/edit-farm-form.tsx new file mode 100644 index 0000000..99ebd15 --- /dev/null +++ b/frontend/app/(sidebar)/farms/edit-farm-form.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { useCallback, useEffect } from "react"; // Added useEffect +import { Loader2 } from "lucide-react"; +import type { Farm } from "@/types"; +import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing"; + +// Schema for editing - make fields optional if needed, but usually same as create +const farmFormSchema = z.object({ + name: z.string().min(2, "Farm name must be at least 2 characters"), + latitude: z + .number({ invalid_type_error: "Latitude must be a number" }) + .min(-90, "Invalid latitude") + .max(90, "Invalid latitude") + .refine((val) => val !== 0, { message: "Please select a location on the map." }), + longitude: z + .number({ invalid_type_error: "Longitude must be a number" }) + .min(-180, "Invalid longitude") + .max(180, "Invalid longitude") + .refine((val) => val !== 0, { message: "Please select a location on the map." }), + type: z.string().min(1, "Please select a farm type"), + area: z.string().optional(), +}); + +export interface EditFarmFormProps { + initialData: Farm; // Require initial data for editing + onSubmit: (data: Partial>) => Promise; // Exclude non-editable fields + onCancel: () => void; + isSubmitting: boolean; +} + +export function EditFarmForm({ initialData, onSubmit, onCancel, isSubmitting }: EditFarmFormProps) { + const form = useForm>({ + resolver: zodResolver(farmFormSchema), + // Set default values from initialData + defaultValues: { + name: initialData.name || "", + latitude: initialData.lat || 0, + longitude: initialData.lon || 0, + type: initialData.farmType || "", + area: initialData.totalSize || "", + }, + }); + + // Update form if initialData changes (e.g., opening dialog for different farms) + useEffect(() => { + form.reset({ + name: initialData.name || "", + latitude: initialData.lat || 0, + longitude: initialData.lon || 0, + type: initialData.farmType || "", + area: initialData.totalSize || "", + }); + }, [initialData, form.reset]); + + const handleSubmit = async (values: z.infer) => { + try { + // Shape data for the API update function + const farmUpdateData: Partial> = { + name: values.name, + lat: values.latitude, + lon: values.longitude, + farmType: values.type, + totalSize: values.area, + }; + await onSubmit(farmUpdateData); + // No need to reset form here, dialog closing handles it or parent component does + } catch (error) { + console.error("Error submitting edit form:", error); + // Error handled by mutation's onError + } + }; + + // Map handler - same as AddFarmForm + const handleShapeDrawn = useCallback( + (data: ShapeData) => { + if (data.type === "marker") { + const { lat, lng } = data.position; + form.setValue("latitude", lat, { shouldValidate: true }); + form.setValue("longitude", lng, { shouldValidate: true }); + } else { + console.log(`Shape type '${data.type}' ignored for coordinate update.`); + } + }, + [form] + ); + + return ( +
+ {/* Form Section */} +
+
+ + {/* Fields: Name, Lat/Lon, Type, Area - same structure as AddFarmForm */} + {/* Farm Name Field */} + ( + + Farm Name + + + + This is your farm's display name. + + + )} + /> + + {/* Coordinate Fields (Latitude & Longitude) */} +
+ ( + + Latitude + + + + + + )} + /> + ( + + Longitude + + + + + + )} + /> +
+ + {/* Farm Type Selection */} + ( + + Farm Type + + + + )} + /> + + {/* Total Area Field */} + ( + + Total Area (optional) + + + + The total size of your farm (e.g., "15 rai", "10 hectares"). + + + )} + /> + + {/* Submit and Cancel Buttons */} +
+ + +
+ + +
+ {/* Map Section */} +
+ Farm Location (Update marker if needed) +
+ +
+ + Click the marker tool and place a new marker to update coordinates. + +
+
+ ); +} diff --git a/frontend/app/(sidebar)/farms/farm-card.tsx b/frontend/app/(sidebar)/farms/farm-card.tsx index 00090ae..4d0a870 100644 --- a/frontend/app/(sidebar)/farms/farm-card.tsx +++ b/frontend/app/(sidebar)/farms/farm-card.tsx @@ -1,19 +1,28 @@ "use client"; import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"; -import { MapPin, Sprout, Plus, CalendarDays, ArrowRight } from "lucide-react"; +import { MapPin, Sprout, Plus, ArrowRight, MoreVertical, Edit, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { Farm } from "@/types"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; export interface FarmCardProps { variant: "farm" | "add"; farm?: Farm; // Use updated Farm type onClick?: () => void; + onEditClick?: (e: React.MouseEvent) => void; // Callback for edit + onDeleteClick?: (e: React.MouseEvent) => void; // Callback for delete } -export function FarmCard({ variant, farm, onClick }: FarmCardProps) { +export function FarmCard({ variant, farm, onClick, onEditClick, onDeleteClick }: FarmCardProps) { const cardClasses = cn( "w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border", variant === "add" @@ -21,6 +30,10 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) { : "bg-white dark:bg-slate-800 hover:bg-muted/10 dark:hover:bg-slate-700 border-muted/60" ); + // Stop propagation for dropdown menu trigger and items + const stopPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); + }; if (variant === "add") { return ( @@ -43,49 +56,81 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) { }).format(new Date(farm.createdAt)); return ( - + -
+
+ className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200 flex-shrink-0"> {farm.farmType} -
- - {formattedDate} -
+ {/* Actions Dropdown */} + + + + + + + + Edit Farm + + + + + Delete Farm + + +
- -
-
- -
-
-

{farm.name}

-
- - {farm.lat} + {/* Use div for clickable area if needed, or rely on button */} +
+ +
+
+
-
-
-

Area

-

{farm.totalSize}

+ {/* Ensure text truncates */} +
+

+ {farm.name} +

+
+ + {/* Display truncated location or just Lat/Lon */} + + Lat: {farm.lat.toFixed(3)}, Lon: {farm.lon.toFixed(3)} +
-
-

Crops

-

{farm.crops ? farm.crops.length : 0}

+
+
+

Area

+

+ {farm.totalSize || "N/A"} +

+
+
+

Crops

+

{farm.crops ? farm.crops.length : 0}

+
-
- - + +
+ + {" "} + {/* Keep footer outside clickable area */} diff --git a/frontend/app/(sidebar)/farms/page.tsx b/frontend/app/(sidebar)/farms/page.tsx index cef3fe3..8b70e1e 100644 --- a/frontend/app/(sidebar)/farms/page.tsx +++ b/frontend/app/(sidebar)/farms/page.tsx @@ -20,11 +20,25 @@ import { } from "@/components/ui/dropdown-menu"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { FarmCard } from "./farm-card"; import { AddFarmForm } from "./add-farm-form"; +import { EditFarmForm } from "./edit-farm-form"; import type { Farm } from "@/types"; -import { fetchFarms, createFarm } from "@/api/farm"; +import { fetchFarms, createFarm, updateFarm, deleteFarm } from "@/api/farm"; +import { toast } from "sonner"; +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; export default function FarmSetupPage() { const router = useRouter(); @@ -33,27 +47,68 @@ export default function FarmSetupPage() { const [searchQuery, setSearchQuery] = useState(""); const [activeFilter, setActiveFilter] = useState("all"); const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest"); - const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); // State for edit dialog + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); // State for delete dialog + const [selectedFarm, setSelectedFarm] = useState(null); // Farm to edit/delete + // --- Fetch Farms --- const { - data: farms, // Type is Farm[] now + data: farms, isLoading, isError, error, } = useQuery({ - // Use Farm[] type queryKey: ["farms"], queryFn: fetchFarms, staleTime: 60 * 1000, }); - const mutation = useMutation({ - // Pass the correct type to createFarm + // --- Create Farm Mutation --- + const createMutation = useMutation({ mutationFn: (data: Partial>) => createFarm(data), - onSuccess: () => { + onSuccess: (newFarm) => { queryClient.invalidateQueries({ queryKey: ["farms"] }); - setIsDialogOpen(false); + setIsAddDialogOpen(false); + toast.success(`Farm "${newFarm.name}" created successfully!`); + }, + onError: (error) => { + toast.error(`Failed to create farm: ${(error as Error).message}`); + }, + }); + + // --- Update Farm Mutation --- + const updateMutation = useMutation({ + mutationFn: (data: { + farmId: string; + payload: Partial>; + }) => updateFarm(data.farmId, data.payload), + onSuccess: (updatedFarm) => { + queryClient.invalidateQueries({ queryKey: ["farms"] }); + setIsEditDialogOpen(false); + setSelectedFarm(null); + toast.success(`Farm "${updatedFarm.name}" updated successfully!`); + }, + onError: (error) => { + toast.error(`Failed to update farm: ${(error as Error).message}`); + }, + }); + + // --- Delete Farm Mutation --- + const deleteMutation = useMutation({ + mutationFn: (farmId: string) => deleteFarm(farmId), + onSuccess: (_, farmId) => { + // Second arg is the variable passed to mutate + queryClient.invalidateQueries({ queryKey: ["farms"] }); + // Optionally remove specific farm query if cached elsewhere: queryClient.removeQueries({ queryKey: ["farm", farmId] }); + setIsDeleteDialogOpen(false); + setSelectedFarm(null); + toast.success(`Farm deleted successfully.`); + }, + onError: (error) => { + toast.error(`Failed to delete farm: ${(error as Error).message}`); + setIsDeleteDialogOpen(false); // Close dialog even on error }, }); @@ -69,6 +124,35 @@ export default function FarmSetupPage() { // UpdatedAt: string; // } + const handleAddFarmSubmit = async (data: Partial) => { + await createMutation.mutateAsync(data); + }; + + const handleEditFarmSubmit = async ( + data: Partial> + ) => { + if (!selectedFarm) return; + await updateMutation.mutateAsync({ farmId: selectedFarm.uuid, payload: data }); + }; + + const openEditDialog = (farm: Farm, e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click + setSelectedFarm(farm); + setIsEditDialogOpen(true); + }; + + const openDeleteDialog = (farm: Farm, e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click + setSelectedFarm(farm); + setIsDeleteDialogOpen(true); + }; + + const confirmDelete = () => { + if (!selectedFarm) return; + deleteMutation.mutate(selectedFarm.uuid); + }; + + // --- Filtering and Sorting Logic --- const filteredAndSortedFarms = (farms || []) .filter( (farm) => @@ -90,10 +174,6 @@ export default function FarmSetupPage() { // Get distinct farm types. const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.farmType))]; // Use camelCase farmType - const handleAddFarm = async (data: Partial) => { - await mutation.mutateAsync(data); - }; - return (
@@ -114,7 +194,7 @@ export default function FarmSetupPage() { onChange={(e) => setSearchQuery(e.target.value)} />
- @@ -128,8 +208,9 @@ export default function FarmSetupPage() { setActiveFilter(type)}> {type === "all" ? "All Farms" : type} @@ -148,25 +229,25 @@ export default function FarmSetupPage() { Sort by setSortOrder("newest")}> Newest first - {sortOrder === "newest" && } + {sortOrder === "newest" && } setSortOrder("oldest")}> Oldest first - {sortOrder === "oldest" && } + {sortOrder === "oldest" && } setSortOrder("alphabetical")}> Alphabetical - {sortOrder === "alphabetical" && } + {sortOrder === "alphabetical" && } @@ -178,21 +259,40 @@ export default function FarmSetupPage() { {isError && ( - Error + Error Loading Farms {(error as Error)?.message} )} {/* Loading state */} {isLoading && ( -
- -

Loading your farms...

+
+ {[...Array(4)].map( + ( + _, + i // Render skeleton cards + ) => ( + + + + + + + + + + + + + + ) + )}
)} {/* Empty state */} {!isLoading && !isError && filteredAndSortedFarms.length === 0 && ( + // ... (Empty state remains the same) ...
@@ -204,7 +304,7 @@ export default function FarmSetupPage() {

) : (

- You haven't added any farms yet. Get started by adding your first farm. + You haven't added any farms yet. Get started by adding your first farm.

)}
); }