feat: add ability to edit farm and crop

This commit is contained in:
Sosokker 2025-04-04 15:30:50 +07:00
parent 1e6c631be3
commit 644b3f940d
7 changed files with 1028 additions and 309 deletions

View File

@ -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
}

View File

@ -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<CropResponse> {
// Assuming backend returns { "croplands": [...] }
return axiosInstance.get<CropResponse>(`/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<Cropland> {
// Assuming backend returns { "cropland": ... }
return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
// If backend returns object directly: return axiosInstance.get<Cropland>(`/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<Omit<Cropland, "uuid" | "createdAt" | "updatedAt">>): Promise<Cropland> {
export async function createCrop(data: {
name: string;
status: string;
priority?: number;
landSize?: number;
growthStage: string;
plantId: string;
farmId: string;
geoFeature?: unknown | null;
}): Promise<Cropland> {
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<Cropland> {
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<Omit<Cropland, "uuid" | "createdA
landSize: data.landSize,
growthStage: data.growthStage,
plantId: data.plantId,
farmId: data.farmId,
geoFeature: data.geoFeature, // Send the GeoFeature object
geoFeature: data.geoFeature,
};
return axiosInstance.post<{ cropland: Cropland }>(`/crop`, payload).then((res) => res.data.cropland); // Assuming backend wraps in { "cropland": ... }
// If backend returns object directly: return axiosInstance.post<Cropland>(`/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<CropAnalytics> {
// Assuming backend returns { body: { ... } } structure from Huma
return axiosInstance.get<CropAnalytics>(`/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<CropAnalytics | null> {
try {
const response = await axiosInstance.get<CropAnalytics>(`/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;
}
}

View File

@ -36,16 +36,17 @@ import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-wi
interface CropDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: Partial<Cropland>) => Promise<void>;
onSubmit: (data: Partial<Omit<Cropland, "uuid" | "farmId">>) => Promise<void>;
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<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
const [calculatedArea, setCalculatedArea] = useState<number | null>(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
<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>
<DialogTitle className="text-xl font-semibold">
{isEditing ? "Edit Cropland" : "Create New Cropland"}
</DialogTitle>
<DialogDescription>
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."}
</DialogDescription>
</DialogHeader>

View File

@ -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<Cropland, "uuid" | "farmId" | "createdAt" | "updatedAt">;
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<Farm>({
@ -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<PlantResponse>({
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 (
<div className="min-h-screen container max-w-7xl p-6 mx-auto">
<Button
@ -129,11 +215,7 @@ export default function CropDetailPage() {
<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>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
</div>
);
@ -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 (
<div className="min-h-screen bg-background text-foreground">
<div className="container max-w-7xl p-6 mx-auto">
{/* Breadcrumbs */}
<nav className="flex items-center text-sm text-muted-foreground mb-4">
<nav className="flex items-center text-sm text-muted-foreground mb-4 flex-wrap">
<Button
variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary"
@ -198,22 +273,25 @@ export default function CropDetailPage() {
<Home className="h-3.5 w-3.5 mr-1" />
Home
</Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" />
<ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
<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" />
<ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
<Button
variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary max-w-[150px] truncate"
title={farm?.name || "Farm"}
onClick={() => router.push(`/farms/${farmId}`)}>
{farm?.name || "Farm"} {/* Use camelCase */}
{farm?.name || "Farm"}
</Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" />
<span className="text-foreground font-medium truncate">{cropland.name || "Crop"}</span> {/* Use camelCase */}
<ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
<span className="text-foreground font-medium truncate" title={cropland.name || "Crop"}>
{cropland.name || "Crop"}
</span>
</nav>
{/* Header */}
@ -226,21 +304,40 @@ export default function CropDetailPage() {
onClick={() => router.push(`/farms/${farmId}`)}>
<ArrowLeft className="h-4 w-4" /> Back to Farm
</Button>
{/* Hover Card (removed for simplicity, add back if needed) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Crop Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsEditCropOpen(true)}>
<Settings className="mr-2 h-4 w-4" />
<span>Edit Crop</span>
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600 focus:bg-red-50 focus:text-red-700"
onClick={() => setIsDeleteDialogOpen(true)}>
<AlertTriangle className="mr-2 h-4 w-4" />
<span>Delete Crop</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1> {/* Use camelCase */}
<h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1>
<p className="text-muted-foreground">
{plant?.variety || "Unknown Variety"} {displayArea} {/* Use camelCase */}
{plant?.variety || "Unknown Variety"} {displayArea}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<Badge variant="outline" className={`${healthColors[healthStatus]} border capitalize`}>
{cropland.status} {/* Use camelCase */}
<Badge variant="outline" className={`${healthColorClass} border capitalize`}>
{cropland.status}
</Badge>
</div>
{expectedHarvestDate ? (
@ -260,23 +357,28 @@ export default function CropDetailPage() {
{/* Left Column */}
<div className="md:col-span-8 space-y-6">
{/* Quick Actions */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid sm:grid-cols-2 gap-4">
{quickActions.map((action) => (
<Button
key={action.title}
variant="outline"
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105 border-border/30`}
disabled={action.disabled}
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${
action.disabled ? "opacity-50 cursor-not-allowed" : `${action.color} hover:scale-105`
} border-border/30`}
onClick={action.onClick}>
<div
className={`p-3 rounded-lg ${action.color.replace(
"text-",
"bg-"
)}/20 group-hover:scale-110 transition-transform`}>
className={`p-3 rounded-lg ${
action.disabled ? "bg-muted" : `${action.color.replace("text-", "bg-")}/20`
} group-hover:scale-110 transition-transform`}>
<action.icon className="h-5 w-5" />
</div>
<div className="text-center">
<div className="font-medium mb-1">{action.title}</div>
<p className="text-xs text-muted-foreground">{action.description}</p>
{action.disabled && action.title === "Analytics" && (
<p className="text-xs text-amber-600 mt-1">(No data)</p>
)}
</div>
</Button>
))}
@ -286,51 +388,70 @@ export default function CropDetailPage() {
<Card className="border-border/30">
<CardHeader>
<CardTitle>Environmental Conditions</CardTitle>
<CardDescription>Real-time monitoring data</CardDescription>
<CardDescription>Real-time monitoring data (if available)</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[
// ... (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() {
</Card>
))}
</div>
<Separator />
{/* Growth Progress */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">Growth Progress</span>
<span className="text-muted-foreground">{growthProgress}%</span>
</div>
<Progress value={growthProgress} className="h-2" />
<p className="text-xs text-muted-foreground">
Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity.
</p>
</div>
{/* Next Action Card */}
<Card className="border-blue-100 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-900/50">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-800">
<Timer className="h-4 w-4 text-blue-600 dark:text-blue-300" />
</div>
<div>
<p className="font-medium mb-1">Next Action Required</p>
<p className="text-sm text-muted-foreground">
{analytics?.nextAction || "Check crop status"}
</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>
)}
{/* Show message if no analytics at all */}
{!analytics && !isLoadingAnalytics && (
<p className="text-center text-sm text-muted-foreground py-4">Environmental data not available.</p>
)}
{analytics && (
<>
<Separator />
{/* Growth Progress */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">Growth Progress</span>
<span className="text-muted-foreground">{growthProgress}%</span>
</div>
<Progress value={growthProgress} className="h-2" />
<p className="text-xs text-muted-foreground">
Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity.
</p>
</div>
</CardContent>
</Card>
{/* Next Action Card */}
<Card className="border-blue-100 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-900/50">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-800">
<Timer className="h-4 w-4 text-blue-600 dark:text-blue-300" />
</div>
<div>
<p className="font-medium mb-1">Next Action Required</p>
<p className="text-sm text-muted-foreground">
{analytics?.nextAction || "Check crop status"}
</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>
</CardContent>
</Card>
</>
)}
</div>
</CardContent>
</Card>
@ -397,11 +526,11 @@ export default function CropDetailPage() {
<CardDescription>Visual representation on the farm</CardDescription>
</CardHeader>
<CardContent className="p-0 h-[400px] overflow-hidden rounded-b-lg">
{/* TODO: Ensure GoogleMapWithDrawing correctly parses and displays GeoFeatureData */}
<GoogleMapWithDrawing
initialFeatures={cropland.geoFeature ? [cropland.geoFeature] : undefined}
drawingMode={null}
editable={false}
initialCenter={farm ? { lat: farm.lat, lng: farm.lon } : undefined}
initialZoom={15}
displayOnly={true}
/>
</CardContent>
</Card>
@ -420,37 +549,39 @@ export default function CropDetailPage() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{
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) => (
<div key={nutrient.name} className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">{nutrient.name}</span>
<span className="text-muted-foreground">{nutrient.value ?? "N/A"}%</span>
{/* 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) => (
<div key={nutrient.name} className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">{nutrient.name}</span>
<span className="text-muted-foreground">{nutrient.value ?? "N/A"}%</span>
</div>
<Progress
value={nutrient.value ?? 0}
className={`h-2 ${
nutrient.value !== null && nutrient.value !== undefined ? nutrient.color : "bg-muted"
}`}
/>
</div>
<Progress
value={nutrient.value ?? 0}
className={`h-2 ${
nutrient.value !== null && nutrient.value !== undefined ? nutrient.color : "bg-muted"
}`}
/>
</div>
))}
{!analytics?.nutrientLevels && (
))
) : (
<p className="text-center text-sm text-muted-foreground py-4">Nutrient data not available.</p>
)}
</div>
@ -467,6 +598,7 @@ export default function CropDetailPage() {
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px] pr-4">
{/* Placeholder - Replace with actual activity log */}
<div className="text-center py-10 text-muted-foreground">No recent activity logged.</div>
</ScrollArea>
</CardContent>
@ -476,28 +608,52 @@ export default function CropDetailPage() {
{/* Dialogs */}
<ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={cropland.name || "this crop"} />
{/* Ensure AnalyticsDialog uses the correct props */}
{/* Conditionally render AnalyticsDialog only if analytics data exists */}
{analytics && (
<AnalyticsDialog
open={isAnalyticsOpen}
onOpenChange={setIsAnalyticsOpen}
// The dialog expects a `Crop` type, but we have `Cropland` and `CropAnalytics`
// We need to construct a simplified `Crop` object or update the dialog prop type
crop={{
// 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
crop={cropland} // Pass the full cropland object
analytics={analytics} // Pass the analytics data
/>
)}
{/* Edit Crop Dialog */}
<CropDialog
open={isEditCropOpen}
onOpenChange={setIsEditCropOpen}
initialData={cropland} // Pass current cropland data to pre-fill the form
onSubmit={async (data) => {
// '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 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the crop &quot;{cropland.name}&quot; and all
associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete Crop
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);

View File

@ -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<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) => Promise<void>; // Exclude non-editable fields
onCancel: () => void;
isSubmitting: boolean;
}
export function EditFarmForm({ initialData, onSubmit, onCancel, isSubmitting }: EditFarmFormProps) {
const form = useForm<z.infer<typeof farmFormSchema>>({
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<typeof farmFormSchema>) => {
try {
// Shape data for the API update function
const farmUpdateData: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">> = {
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 (
<div className="flex flex-col lg:flex-row gap-6 p-4">
{/* Form Section */}
<div className="lg:flex-[1]">
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
{/* Fields: Name, Lat/Lon, Type, Area - same structure as AddFarmForm */}
{/* Farm Name Field */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Farm Name</FormLabel>
<FormControl>
<Input placeholder="Enter farm name" {...field} />
</FormControl>
<FormDescription>This is your farm's display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Coordinate Fields (Latitude & Longitude) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
control={form.control}
name="latitude"
render={({ field }) => (
<FormItem>
<FormLabel>Latitude</FormLabel>
<FormControl>
<Input
placeholder="Select on map"
{...field}
value={field.value ? field.value.toFixed(6) : ""}
disabled
readOnly
className="disabled:opacity-100 disabled:cursor-default"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="longitude"
render={({ field }) => (
<FormItem>
<FormLabel>Longitude</FormLabel>
<FormControl>
<Input
placeholder="Select on map"
{...field}
value={field.value ? field.value.toFixed(6) : ""}
disabled
readOnly
className="disabled:opacity-100 disabled:cursor-default"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Farm Type Selection */}
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Farm Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select farm type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="durian">Durian</SelectItem>
<SelectItem value="mango">Mango</SelectItem>
<SelectItem value="rice">Rice</SelectItem>
<SelectItem value="mixed">Mixed Crops</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Total Area Field */}
<FormField
control={form.control}
name="area"
render={({ field }) => (
<FormItem>
<FormLabel>Total Area (optional)</FormLabel>
<FormControl>
<Input placeholder="e.g., 10 hectares" {...field} value={field.value ?? ""} />
</FormControl>
<FormDescription>The total size of your farm (e.g., "15 rai", "10 hectares").</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Submit and Cancel Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} className="bg-blue-600 hover:bg-blue-700">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save Changes"
)}
</Button>
</div>
</form>
</Form>
</div>
{/* Map Section */}
<div className="lg:flex-[2] min-h-[400px] lg:min-h-0 flex flex-col">
<FormLabel>Farm Location (Update marker if needed)</FormLabel>
<div className="mt-2 rounded-md overflow-hidden border flex-grow">
<GoogleMapWithDrawing
onShapeDrawn={handleShapeDrawn}
// Pass initial coordinates to center the map
initialCenter={{ lat: initialData.lat, lng: initialData.lon }}
initialZoom={15} // Or a suitable zoom level
// You could potentially pass the existing farm marker as an initial feature:
initialFeatures={[{ type: "marker", position: { lat: initialData.lat, lng: initialData.lon } }]}
/>
</div>
<FormDescription className="mt-2">
Click the marker tool and place a new marker to update coordinates.
</FormDescription>
</div>
</div>
);
}

View File

@ -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 (
<Card className={cardClasses} onClick={onClick}>
@ -43,49 +56,81 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
}).format(new Date(farm.createdAt));
return (
<Card className={cardClasses} onClick={onClick}>
<Card className={cardClasses}>
<CardHeader className="p-4 pb-0">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<Badge
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 flex-shrink-0">
{farm.farmType}
</Badge>
<div className="flex items-center text-xs text-muted-foreground">
<CalendarDays className="h-3 w-3 mr-1" />
{formattedDate}
</div>
{/* Actions Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={stopPropagation}>
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:bg-muted/50">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Farm Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={stopPropagation}>
<DropdownMenuItem onClick={onEditClick}>
<Edit className="mr-2 h-4 w-4" />
<span>Edit Farm</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
onClick={onDeleteClick}>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Farm</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center">
<Sprout className="h-5 w-5 text-green-600" />
</div>
<div>
<h3 className="text-xl font-medium mb-1 truncate">{farm.name}</h3>
<div className="flex items-center text-sm text-muted-foreground mb-2">
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
<span className="truncate">{farm.lat}</span>
{/* Use div for clickable area if needed, or rely on button */}
<div className="flex-grow cursor-pointer" onClick={onClick}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center bg-muted/40">
<Sprout className="h-5 w-5 text-primary" />
</div>
<div className="grid grid-cols-2 gap-2 mt-3">
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
<p className="text-xs text-muted-foreground">Area</p>
<p className="font-medium">{farm.totalSize}</p>
{/* Ensure text truncates */}
<div className="min-w-0 flex-1">
<h3 className="text-lg font-medium mb-1 truncate" title={farm.name}>
{farm.name}
</h3>
<div className="flex items-center text-sm text-muted-foreground mb-2 truncate">
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
{/* Display truncated location or just Lat/Lon */}
<span className="truncate" title={`Lat: ${farm.lat}, Lon: ${farm.lon}`}>
Lat: {farm.lat.toFixed(3)}, Lon: {farm.lon.toFixed(3)}
</span>
</div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
<p className="text-xs text-muted-foreground">Crops</p>
<p className="font-medium">{farm.crops ? farm.crops.length : 0}</p>
<div className="grid grid-cols-2 gap-2 mt-3">
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
<p className="text-xs text-muted-foreground">Area</p>
<p className="font-medium truncate" title={farm.totalSize || "N/A"}>
{farm.totalSize || "N/A"}
</p>
</div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
<p className="text-xs text-muted-foreground">Crops</p>
<p className="font-medium">{farm.crops ? farm.crops.length : 0}</p>
</div>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter className="p-4 pt-0">
</CardContent>
</div>
<CardFooter className="p-4 pt-0 mt-auto">
{" "}
{/* Keep footer outside clickable area */}
<Button
variant="ghost"
size="sm"
className="ml-auto gap-1 text-green-600 hover:text-green-700 hover:bg-green-50/50 dark:hover:bg-green-800">
className="ml-auto gap-1 text-primary hover:text-primary/80 hover:bg-primary/10"
onClick={onClick}>
View details <ArrowRight className="h-3.5 w-3.5" />
</Button>
</CardFooter>

View File

@ -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<string>("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<Farm | null>(null); // Farm to edit/delete
// --- Fetch Farms ---
const {
data: farms, // Type is Farm[] now
data: farms,
isLoading,
isError,
error,
} = useQuery<Farm[]>({
// 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<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) =>
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<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>;
}) => 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<Farm>) => {
await createMutation.mutateAsync(data);
};
const handleEditFarmSubmit = async (
data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>
) => {
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<Farm>) => {
await mutation.mutateAsync(data);
};
return (
<div className="min-h-screen bg-gradient-to-b">
<div className="container max-w-7xl p-6 mx-auto">
@ -114,7 +194,7 @@ export default function FarmSetupPage() {
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2 bg-green-600 hover:bg-green-700">
<Button onClick={() => setIsAddDialogOpen(true)} className="gap-2 bg-green-600 hover:bg-green-700">
<Plus className="h-4 w-4" />
Add Farm
</Button>
@ -128,8 +208,9 @@ export default function FarmSetupPage() {
<Badge
key={type}
variant={activeFilter === type ? "default" : "outline"}
className={`capitalize cursor-pointer ${
activeFilter === type ? "bg-green-600" : "hover:bg-green-100"
className={`capitalize cursor-pointer rounded-full px-3 py-1 text-sm ${
// Made rounded-full
activeFilter === type ? "bg-primary text-primary-foreground" : "hover:bg-accent" // Adjusted colors
}`}
onClick={() => setActiveFilter(type)}>
{type === "all" ? "All Farms" : type}
@ -148,25 +229,25 @@ export default function FarmSetupPage() {
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className={sortOrder === "newest" ? "bg-green-50" : ""}
className={sortOrder === "newest" ? "bg-accent" : ""} // Use accent for selection
onClick={() => setSortOrder("newest")}>
<Calendar className="h-4 w-4 mr-2" />
Newest first
{sortOrder === "newest" && <Check className="h-4 w-4 ml-2" />}
{sortOrder === "newest" && <Check className="h-4 w-4 ml-auto" />}
</DropdownMenuItem>
<DropdownMenuItem
className={sortOrder === "oldest" ? "bg-green-50" : ""}
className={sortOrder === "oldest" ? "bg-accent" : ""}
onClick={() => setSortOrder("oldest")}>
<Calendar className="h-4 w-4 mr-2" />
Oldest first
{sortOrder === "oldest" && <Check className="h-4 w-4 ml-2" />}
{sortOrder === "oldest" && <Check className="h-4 w-4 ml-auto" />}
</DropdownMenuItem>
<DropdownMenuItem
className={sortOrder === "alphabetical" ? "bg-green-50" : ""}
className={sortOrder === "alphabetical" ? "bg-accent" : ""}
onClick={() => setSortOrder("alphabetical")}>
<Filter className="h-4 w-4 mr-2" />
Alphabetical
{sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-2" />}
{sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-auto" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -178,21 +259,40 @@ export default function FarmSetupPage() {
{isError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertTitle>Error Loading Farms</AlertTitle>
<AlertDescription>{(error as Error)?.message}</AlertDescription>
</Alert>
)}
{/* Loading state */}
{isLoading && (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" />
<p className="text-muted-foreground">Loading your farms...</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(4)].map(
(
_,
i // Render skeleton cards
) => (
<Card key={i} className="w-full h-[250px]">
<CardHeader className="p-4 pb-0">
<Skeleton className="h-4 w-1/3" />
</CardHeader>
<CardContent className="p-4 space-y-3">
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</CardContent>
<CardFooter className="p-4 pt-0">
<Skeleton className="h-8 w-24 ml-auto" />
</CardFooter>
</Card>
)
)}
</div>
)}
{/* Empty state */}
{!isLoading && !isError && filteredAndSortedFarms.length === 0 && (
// ... (Empty state remains the same) ...
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 rounded-lg border border-dashed">
<div className="bg-green-100 p-3 rounded-full mb-4">
<Leaf className="h-6 w-6 text-green-600" />
@ -204,7 +304,7 @@ export default function FarmSetupPage() {
</p>
) : (
<p className="text-muted-foreground text-center max-w-md mb-6">
You haven&apos;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.
</p>
)}
<Button
@ -212,7 +312,7 @@ export default function FarmSetupPage() {
setSearchQuery("");
setActiveFilter("all");
if (!farms || farms.length === 0) {
setIsDialogOpen(true);
setIsAddDialogOpen(true);
}
}}
className="gap-2">
@ -232,17 +332,31 @@ export default function FarmSetupPage() {
{!isLoading && !isError && filteredAndSortedFarms.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence>
<motion.div /* ... */>
<FarmCard variant="add" onClick={() => setIsDialogOpen(true)} />
{/* Add Farm Card */}
<motion.div
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}>
<FarmCard variant="add" onClick={() => setIsAddDialogOpen(true)} />
</motion.div>
{/* Existing Farm Cards */}
{filteredAndSortedFarms.map((farm, index) => (
<motion.div
key={farm.uuid} // Use camelCase uuid initial={{ opacity: 0, y: 20 }}
layout // Add layout animation
key={farm.uuid}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="col-span-1">
<FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.uuid}`)} />
<FarmCard
variant="farm"
farm={farm}
onClick={() => router.push(`/farms/${farm.uuid}`)}
onEditClick={(e) => openEditDialog(farm, e)} // Pass handler
onDeleteClick={(e) => openDeleteDialog(farm, e)} // Pass handler
/>
</motion.div>
))}
</AnimatePresence>
@ -252,16 +366,57 @@ export default function FarmSetupPage() {
</div>
{/* Add Farm Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent className="sm:max-w-[800px] md:max-w-[900px] lg:max-w-[1000px] xl:max-w-5xl">
<DialogHeader>
<DialogTitle>Add New Farm</DialogTitle>
<DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription>
</DialogHeader>
{/* Pass handleAddFarm (which now expects Partial<Farm>) */}
<AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} />
<AddFarmForm onSubmit={handleAddFarmSubmit} onCancel={() => setIsAddDialogOpen(false)} />
</DialogContent>
</Dialog>
{/* Edit Farm Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[800px] md:max-w-[900px] lg:max-w-[1000px] xl:max-w-5xl">
<DialogHeader>
<DialogTitle>Edit Farm: {selectedFarm?.name}</DialogTitle>
<DialogDescription>Update the details for this farm.</DialogDescription>
</DialogHeader>
{/* Create or use an EditFarmForm component */}
{selectedFarm && (
<EditFarmForm
initialData={selectedFarm}
onSubmit={handleEditFarmSubmit}
onCancel={() => setIsEditDialogOpen(false)}
isSubmitting={updateMutation.isPending} // Pass submitting state
/>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the farm "{selectedFarm?.name}" and all
associated crops and data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete Farm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}