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) { func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
tags := []string{"crop"} tags := []string{"crop"}
prefix := "/crop" prefix := "/crop"
// Register GET /crop
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getAllCroplands", OperationID: "getAllCroplands",
Method: http.MethodGet, Method: http.MethodGet,
@ -26,7 +24,6 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
Tags: tags, Tags: tags,
}, a.getAllCroplandsHandler) }, a.getAllCroplandsHandler)
// Register GET /crop/{uuid}
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getCroplandByID", OperationID: "getCroplandByID",
Method: http.MethodGet, Method: http.MethodGet,
@ -34,7 +31,6 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
Tags: tags, Tags: tags,
}, a.getCroplandByIDHandler) }, a.getCroplandByIDHandler)
// Register GET /crop/farm/{farm_id}
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getAllCroplandsByFarmID", OperationID: "getAllCroplandsByFarmID",
Method: http.MethodGet, Method: http.MethodGet,
@ -42,15 +38,23 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
Tags: tags, Tags: tags,
}, a.getAllCroplandsByFarmIDHandler) }, a.getAllCroplandsByFarmIDHandler)
// Register POST /crop (Create or Update)
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "createOrUpdateCropland", OperationID: "createCropland",
Method: http.MethodPost, Method: http.MethodPost,
Path: prefix, Path: prefix,
Tags: tags, 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 { type GetCroplandsOutput struct {
Body struct { Body struct {
Croplands []domain.Cropland `json:"croplands"` 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"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
Body struct { Body struct {
UUID string `json:"uuid,omitempty"` Name string `json:"name" required:"true"`
Name string `json:"name"` Status string `json:"status" required:"true"`
Status string `json:"status"`
Priority int `json:"priority"` Priority int `json:"priority"`
LandSize float64 `json:"landSize"` LandSize float64 `json:"landSize"`
GrowthStage string `json:"growthStage"` GrowthStage string `json:"growthStage" required:"true"`
PlantID string `json:"plantId"` PlantID string `json:"plantId" required:"true" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"`
FarmID string `json:"farmId"` FarmID string `json:"farmId" required:"true" example:"b2c3d4e5-f6a7-8901-2345-67890abcdef0"`
GeoFeature json.RawMessage `json:"geoFeature,omitempty"` 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 { Body struct {
Cropland domain.Cropland `json:"cropland"` 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"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
}) (*GetCroplandsOutput, error) { }) (*GetCroplandsOutput, error) {
// Note: This currently fetches ALL croplands. Might need owner filtering later. // Note: This currently fetches ALL croplands. Might need owner filtering later.
// For now, ensure authentication happens. _, err := a.getUserIDFromHeader(input.Header)
_, err := a.getUserIDFromHeader(input.Header) // Verify token
if err != nil { if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err) 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"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"`
}) (*GetCroplandByIDOutput, error) { }) (*GetCroplandByIDOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header) // Verify token and get user ID userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err) return nil, huma.Error401Unauthorized("Authentication failed", err)
} }
@ -120,15 +146,15 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct {
resp := &GetCroplandByIDOutput{} resp := &GetCroplandByIDOutput{}
if input.UUID == "" { 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 { if err != nil {
return nil, huma.Error400BadRequest("Invalid UUID format") 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 err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
a.logger.Warn("Cropland not found", "croplandId", input.UUID, "requestingUserId", userID) 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") 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)
farm, err := a.farmRepo.GetByID(ctx, cropland.FarmID) // Fetch the farm
if err != nil { if err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { 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) 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") 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) 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{} resp := &GetCroplandsOutput{}
if input.FarmID == "" { 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) 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") 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()) farm, err := a.farmRepo.GetByID(ctx, farmUUID.String())
if err != nil { if err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { 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 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) userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err) 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 { if _, err := uuid.FromString(input.Body.PlantID); err != nil {
return nil, huma.Error400BadRequest("invalid plantId UUID format") return nil, huma.Error400BadRequest("invalid plantId UUID format")
} }
farmUUID, err := uuid.FromString(input.Body.FarmID) farmUUID, err := uuid.FromString(input.Body.FarmID)
if err != nil { 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) { if input.Body.GeoFeature != nil && !json.Valid(input.Body.GeoFeature) {
return nil, huma.Error400BadRequest("invalid JSON format for 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()) farm, err := a.farmRepo.GetByID(ctx, farmUUID.String())
if err != nil { if err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { 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") 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") return nil, huma.Error500InternalServerError("Failed to verify ownership")
} }
if farm.OwnerID != userID { if farm.OwnerID != userID {
a.logger.Warn("Unauthorized attempt to create/update crop on farm", "farmId", input.Body.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID) 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 modify crops on this farm") 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{ cropland := &domain.Cropland{
UUID: input.Body.UUID,
Name: input.Body.Name, Name: input.Body.Name,
Status: input.Body.Status, Status: input.Body.Status,
Priority: input.Body.Priority, Priority: input.Body.Priority,
@ -295,15 +276,84 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr
GeoFeature: input.Body.GeoFeature, GeoFeature: input.Body.GeoFeature,
} }
// Use the repository's CreateOrUpdate which handles assigning UUID if needed
err = a.cropRepo.CreateOrUpdate(ctx, cropland) err = a.cropRepo.CreateOrUpdate(ctx, cropland)
if err != nil { 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") 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 resp.Body.Cropland = *cropland
return resp, nil 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"; import axiosInstance from "./config";
// Use refactored types
import type { Cropland, CropAnalytics } from "@/types"; import type { Cropland, CropAnalytics } from "@/types";
export interface CropResponse { 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> { export async function getCropsByFarmId(farmId: string): Promise<CropResponse> {
// Assuming backend returns { "croplands": [...] } return axiosInstance.get<{ croplands: Cropland[] }>(`/crop/farm/${farmId}`).then((res) => res.data);
return axiosInstance.get<CropResponse>(`/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> { export async function getCropById(cropId: string): Promise<Cropland> {
// Assuming backend returns { "cropland": ... } const response = await axiosInstance.get<{ cropland: Cropland }>(`/crop/${cropId}`);
return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data); return response.data.cropland;
// If backend returns object directly: return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
} }
/** /**
* 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) { if (!data.farmId) {
throw new Error("farmId is required to create a crop."); 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 = { const payload = {
name: data.name, name: data.name,
status: data.status, status: data.status,
@ -38,17 +80,36 @@ export async function createCrop(data: Partial<Omit<Cropland, "uuid" | "createdA
landSize: data.landSize, landSize: data.landSize,
growthStage: data.growthStage, growthStage: data.growthStage,
plantId: data.plantId, plantId: data.plantId,
farmId: data.farmId, geoFeature: data.geoFeature,
geoFeature: data.geoFeature, // Send the GeoFeature object
}; };
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> { export async function deleteCrop(cropId: string): Promise<{ message: string } | void> {
// Assuming backend returns { body: { ... } } structure from Huma const response = await axiosInstance.delete(`/crop/${cropId}`);
return axiosInstance.get<CropAnalytics>(`/analytics/crop/${cropId}`).then((res) => res.data); 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 { interface CropDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (data: Partial<Cropland>) => Promise<void>; onSubmit: (data: Partial<Omit<Cropland, "uuid" | "farmId">>) => Promise<void>;
isSubmitting: boolean; 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 --- // --- State ---
const [selectedPlantUUID, setSelectedPlantUUID] = useState<string | null>(null); const [selectedPlantUUID, setSelectedPlantUUID] = useState<string | null>(null);
// State to hold the structured GeoFeature data
const [geoFeature, setGeoFeature] = useState<GeoFeatureData | null>(null); 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 --- // --- Load Google Maps Geometry Library ---
const geometryLib = useMapsLibrary("geometry"); const geometryLib = useMapsLibrary("geometry");
@ -63,6 +64,7 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const plants = useMemo(() => plantData?.plants || [], [plantData]); const plants = useMemo(() => plantData?.plants || [], [plantData]);
const selectedPlant = useMemo(() => { const selectedPlant = useMemo(() => {
return plants.find((p) => p.uuid === selectedPlantUUID); return plants.find((p) => p.uuid === selectedPlantUUID);
}, [plants, selectedPlantUUID]); }, [plants, selectedPlantUUID]);
@ -71,10 +73,14 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setSelectedPlantUUID(null); setSelectedPlantUUID(null);
setGeoFeature(null); // Reset geoFeature state setGeoFeature(null);
setCalculatedArea(null); setCalculatedArea(null);
} else if (initialData) {
setSelectedPlantUUID(initialData.plantId);
setGeoFeature(initialData.geoFeature ?? null);
setCalculatedArea(initialData.landSize ?? null);
} }
}, [open]); }, [open, initialData]);
// --- Map Interaction Handler --- // --- Map Interaction Handler ---
const handleShapeDrawn = useCallback( const handleShapeDrawn = useCallback(
@ -169,9 +175,13 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD
<Dialog open={open} onOpenChange={onOpenChange}> <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"> <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"> <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> <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> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -2,7 +2,7 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { useRouter, useParams } from "next/navigation"; import { useRouter, useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowLeft,
LineChart, LineChart,
@ -11,7 +11,6 @@ import {
Sun, Sun,
ThermometerSun, ThermometerSun,
Timer, Timer,
ListCollapse,
Leaf, Leaf,
CloudRain, CloudRain,
Wind, Wind,
@ -22,6 +21,7 @@ import {
LeafIcon, LeafIcon,
History, History,
Bot, Bot,
MoreHorizontal,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; 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 type { Cropland, CropAnalytics, Farm } from "@/types";
import { getFarm } from "@/api/farm"; import { getFarm } from "@/api/farm";
import { getPlants, PlantResponse } from "@/api/plant"; 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 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() { export default function CropDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ farmId: string; cropId: string }>(); const params = useParams<{ farmId: string; cropId: string }>();
const { farmId, cropId } = params; const { farmId, cropId } = params;
const queryClient = useQueryClient();
const [isChatOpen, setIsChatOpen] = useState(false); const [isChatOpen, setIsChatOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const [isEditCropOpen, setIsEditCropOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
// --- Fetch Farm Data --- // --- Fetch Farm Data ---
const { data: farm, isLoading: isLoadingFarm } = useQuery<Farm>({ const { data: farm, isLoading: isLoadingFarm } = useQuery<Farm>({
@ -64,7 +90,7 @@ export default function CropDetailPage() {
queryKey: ["crop", cropId], queryKey: ["crop", cropId],
queryFn: () => getCropById(cropId), queryFn: () => getCropById(cropId),
enabled: !!cropId, enabled: !!cropId,
staleTime: 60 * 1000, staleTime: 60 * 1000, // Refetch more often than farm/plants
}); });
// --- Fetch All Plants Data --- // --- Fetch All Plants Data ---
@ -76,7 +102,7 @@ export default function CropDetailPage() {
} = useQuery<PlantResponse>({ } = useQuery<PlantResponse>({
queryKey: ["plants"], queryKey: ["plants"],
queryFn: getPlants, queryFn: getPlants,
staleTime: 1000 * 60 * 60, staleTime: 1000 * 60 * 60, // Plants data is relatively static
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
@ -88,7 +114,7 @@ export default function CropDetailPage() {
// --- Fetch Crop Analytics Data --- // --- Fetch Crop Analytics Data ---
const { const {
data: analytics, // Type is CropAnalytics | null data: analytics,
isLoading: isLoadingAnalytics, isLoading: isLoadingAnalytics,
isError: isErrorAnalytics, isError: isErrorAnalytics,
error: errorAnalytics, error: errorAnalytics,
@ -99,9 +125,66 @@ export default function CropDetailPage() {
staleTime: 5 * 60 * 1000, 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 --- // --- Combined Loading and Error States ---
const isLoading = isLoadingFarm || isLoadingCropland || isLoadingPlants || isLoadingAnalytics; 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; const error = errorCropland || errorPlants || errorAnalytics;
// --- Loading State --- // --- Loading State ---
@ -117,6 +200,9 @@ export default function CropDetailPage() {
// --- Error State --- // --- Error State ---
if (isError || !cropland) { if (isError || !cropland) {
console.error("Error loading crop details:", error); 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 ( return (
<div className="min-h-screen container max-w-7xl p-6 mx-auto"> <div className="min-h-screen container max-w-7xl p-6 mx-auto">
<Button <Button
@ -129,11 +215,7 @@ export default function CropDetailPage() {
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Error Loading Crop Details</AlertTitle> <AlertTitle>Error Loading Crop Details</AlertTitle>
<AlertDescription> <AlertDescription>{errorMessage}</AlertDescription>
{isErrorCropland
? `Crop with ID ${cropId} not found or could not be loaded.`
: (error as Error)?.message || "An unexpected error occurred."}
</AlertDescription>
</Alert> </Alert>
</div> </div>
); );
@ -144,8 +226,11 @@ export default function CropDetailPage() {
good: "text-green-500 bg-green-50 dark:bg-green-900 border-green-200", 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", 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", 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 = [ const quickActions = [
{ {
@ -154,6 +239,7 @@ export default function CropDetailPage() {
description: "View detailed growth analytics", description: "View detailed growth analytics",
onClick: () => setIsAnalyticsOpen(true), onClick: () => setIsAnalyticsOpen(true),
color: "bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300", 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", title: "Chat Assistant",
@ -162,35 +248,24 @@ export default function CropDetailPage() {
onClick: () => setIsChatOpen(true), onClick: () => setIsChatOpen(true),
color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300", color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300",
}, },
{ // Settings moved to dropdown
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",
},
]; ];
const plantedDate = cropland.createdAt ? new Date(cropland.createdAt) : null; const plantedDate = cropland.createdAt ? new Date(cropland.createdAt) : null;
const daysToMaturity = plant?.daysToMaturity; // Use camelCase const daysToMaturity = plant?.daysToMaturity;
const expectedHarvestDate = 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 growthProgress = analytics?.growthProgress ?? 0;
const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize} ha` : "N/A"; // Use camelCase const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize.toFixed(2)} ha` : "N/A";
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<div className="container max-w-7xl p-6 mx-auto"> <div className="container max-w-7xl p-6 mx-auto">
{/* Breadcrumbs */} {/* 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 <Button
variant="link" variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary" 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 className="h-3.5 w-3.5 mr-1" />
Home Home
</Button> </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 <Button
variant="link" variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary" className="p-0 h-auto font-normal text-muted-foreground hover:text-primary"
onClick={() => router.push("/farms")}> onClick={() => router.push("/farms")}>
Farms Farms
</Button> </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 <Button
variant="link" variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary max-w-[150px] truncate" 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}`)}> onClick={() => router.push(`/farms/${farmId}`)}>
{farm?.name || "Farm"} {/* Use camelCase */} {farm?.name || "Farm"}
</Button> </Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" /> <ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
<span className="text-foreground font-medium truncate">{cropland.name || "Crop"}</span> {/* Use camelCase */} <span className="text-foreground font-medium truncate" title={cropland.name || "Crop"}>
{cropland.name || "Crop"}
</span>
</nav> </nav>
{/* Header */} {/* Header */}
@ -226,21 +304,40 @@ export default function CropDetailPage() {
onClick={() => router.push(`/farms/${farmId}`)}> onClick={() => router.push(`/farms/${farmId}`)}>
<ArrowLeft className="h-4 w-4" /> Back to Farm <ArrowLeft className="h-4 w-4" /> Back to Farm
</Button> </Button>
{/* 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>
<div className="flex flex-col md:flex-row justify-between gap-4"> <div className="flex flex-col md:flex-row justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1> {/* Use camelCase */} <h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{plant?.variety || "Unknown Variety"} {displayArea} {/* Use camelCase */} {plant?.variety || "Unknown Variety"} {displayArea}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className={`${healthColors[healthStatus]} border capitalize`}> <Badge variant="outline" className={`${healthColorClass} border capitalize`}>
{cropland.status} {/* Use camelCase */} {cropland.status}
</Badge> </Badge>
</div> </div>
{expectedHarvestDate ? ( {expectedHarvestDate ? (
@ -260,23 +357,28 @@ export default function CropDetailPage() {
{/* Left Column */} {/* Left Column */}
<div className="md:col-span-8 space-y-6"> <div className="md:col-span-8 space-y-6">
{/* Quick Actions */} {/* 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) => ( {quickActions.map((action) => (
<Button <Button
key={action.title} key={action.title}
variant="outline" variant="outline"
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105 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}> onClick={action.onClick}>
<div <div
className={`p-3 rounded-lg ${action.color.replace( className={`p-3 rounded-lg ${
"text-", action.disabled ? "bg-muted" : `${action.color.replace("text-", "bg-")}/20`
"bg-" } group-hover:scale-110 transition-transform`}>
)}/20 group-hover:scale-110 transition-transform`}>
<action.icon className="h-5 w-5" /> <action.icon className="h-5 w-5" />
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="font-medium mb-1">{action.title}</div> <div className="font-medium mb-1">{action.title}</div>
<p className="text-xs text-muted-foreground">{action.description}</p> <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> </div>
</Button> </Button>
))} ))}
@ -286,51 +388,70 @@ export default function CropDetailPage() {
<Card className="border-border/30"> <Card className="border-border/30">
<CardHeader> <CardHeader>
<CardTitle>Environmental Conditions</CardTitle> <CardTitle>Environmental Conditions</CardTitle>
<CardDescription>Real-time monitoring data</CardDescription> <CardDescription>Real-time monitoring data (if available)</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-6"> <div className="grid gap-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[ {[
// ... (metric definitions remain the same)
{ {
icon: ThermometerSun, icon: ThermometerSun,
label: "Temperature", 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", color: "text-orange-500 dark:text-orange-300",
bg: "bg-orange-50 dark:bg-orange-900", bg: "bg-orange-50 dark:bg-orange-900",
}, },
{ {
icon: Droplets, icon: Droplets,
label: "Humidity", label: "Humidity",
value: analytics?.humidity ? `${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", color: "text-blue-500 dark:text-blue-300",
bg: "bg-blue-50 dark:bg-blue-900", bg: "bg-blue-50 dark:bg-blue-900",
}, },
{ {
icon: Sun, icon: Sun,
label: "Sunlight", label: "Sunlight",
value: analytics?.sunlight ? `${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", color: "text-yellow-500 dark:text-yellow-300",
bg: "bg-yellow-50 dark:bg-yellow-900", bg: "bg-yellow-50 dark:bg-yellow-900",
}, },
{ {
icon: Leaf, icon: Leaf,
label: "Soil Moisture", label: "Soil Moisture",
value: analytics?.soilMoisture ? `${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", color: "text-green-500 dark:text-green-300",
bg: "bg-green-50 dark:bg-green-900", bg: "bg-green-50 dark:bg-green-900",
}, },
{ {
icon: Wind, icon: Wind,
label: "Wind Speed", label: "Wind Speed",
value: analytics?.windSpeed ?? "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", color: "text-gray-500 dark:text-gray-300",
bg: "bg-gray-50 dark:bg-gray-900", bg: "bg-gray-50 dark:bg-gray-900",
}, },
{ {
icon: CloudRain, icon: CloudRain,
label: "Rainfall", label: "Rainfall (1h)",
value: analytics?.rainfall ?? "N/A", value:
analytics?.rainfall !== null && analytics?.rainfall !== undefined
? `${analytics.rainfall.toFixed(1)} mm`
: "N/A",
color: "text-indigo-500 dark:text-indigo-300", color: "text-indigo-500 dark:text-indigo-300",
bg: "bg-indigo-50 dark:bg-indigo-900", bg: "bg-indigo-50 dark:bg-indigo-900",
}, },
@ -350,42 +471,50 @@ export default function CropDetailPage() {
</Card> </Card>
))} ))}
</div> </div>
<Separator /> {/* Show message if no analytics at all */}
{/* Growth Progress */} {!analytics && !isLoadingAnalytics && (
<div className="space-y-2"> <p className="text-center text-sm text-muted-foreground py-4">Environmental data not available.</p>
<div className="flex justify-between text-sm"> )}
<span className="font-medium">Growth Progress</span> {analytics && (
<span className="text-muted-foreground">{growthProgress}%</span> <>
</div> <Separator />
<Progress value={growthProgress} className="h-2" /> {/* Growth Progress */}
<p className="text-xs text-muted-foreground"> <div className="space-y-2">
Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity. <div className="flex justify-between text-sm">
</p> <span className="font-medium">Growth Progress</span>
</div> <span className="text-muted-foreground">{growthProgress}%</span>
{/* 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>
<Progress value={growthProgress} className="h-2" />
<p className="text-xs text-muted-foreground">
Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity.
</p>
</div> </div>
</CardContent> {/* Next Action Card */}
</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> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -397,11 +526,11 @@ export default function CropDetailPage() {
<CardDescription>Visual representation on the farm</CardDescription> <CardDescription>Visual representation on the farm</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0 h-[400px] overflow-hidden rounded-b-lg"> <CardContent className="p-0 h-[400px] overflow-hidden rounded-b-lg">
{/* TODO: Ensure GoogleMapWithDrawing correctly parses and displays GeoFeatureData */}
<GoogleMapWithDrawing <GoogleMapWithDrawing
initialFeatures={cropland.geoFeature ? [cropland.geoFeature] : undefined} initialFeatures={cropland.geoFeature ? [cropland.geoFeature] : undefined}
drawingMode={null} initialCenter={farm ? { lat: farm.lat, lng: farm.lon } : undefined}
editable={false} initialZoom={15}
displayOnly={true}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -420,37 +549,39 @@ export default function CropDetailPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{[ {/* 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: "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: "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", name: "Potassium (K)",
}, value: analytics.nutrientLevels.potassium,
].map((nutrient) => ( color: "bg-green-500 dark:bg-green-700",
<div key={nutrient.name} className="space-y-2"> },
<div className="flex justify-between text-sm"> ].map((nutrient) => (
<span className="font-medium">{nutrient.name}</span> <div key={nutrient.name} className="space-y-2">
<span className="text-muted-foreground">{nutrient.value ?? "N/A"}%</span> <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> </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> <p className="text-center text-sm text-muted-foreground py-4">Nutrient data not available.</p>
)} )}
</div> </div>
@ -467,6 +598,7 @@ export default function CropDetailPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ScrollArea className="h-[300px] pr-4"> <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> <div className="text-center py-10 text-muted-foreground">No recent activity logged.</div>
</ScrollArea> </ScrollArea>
</CardContent> </CardContent>
@ -476,28 +608,52 @@ export default function CropDetailPage() {
{/* Dialogs */} {/* Dialogs */}
<ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={cropland.name || "this crop"} /> <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 && ( {analytics && (
<AnalyticsDialog <AnalyticsDialog
open={isAnalyticsOpen} open={isAnalyticsOpen}
onOpenChange={setIsAnalyticsOpen} onOpenChange={setIsAnalyticsOpen}
// The dialog expects a `Crop` type, but we have `Cropland` and `CropAnalytics` crop={cropland} // Pass the full cropland object
// We need to construct a simplified `Crop` object or update the dialog prop type analytics={analytics} // Pass the analytics data
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
/> />
)} )}
{/* 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>
</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"; "use client";
import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Farm } from "@/types"; import type { Farm } from "@/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface FarmCardProps { export interface FarmCardProps {
variant: "farm" | "add"; variant: "farm" | "add";
farm?: Farm; // Use updated Farm type farm?: Farm; // Use updated Farm type
onClick?: () => void; 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( const cardClasses = cn(
"w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border", "w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border",
variant === "add" 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" : "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") { if (variant === "add") {
return ( return (
<Card className={cardClasses} onClick={onClick}> <Card className={cardClasses} onClick={onClick}>
@ -43,49 +56,81 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
}).format(new Date(farm.createdAt)); }).format(new Date(farm.createdAt));
return ( return (
<Card className={cardClasses} onClick={onClick}> <Card className={cardClasses}>
<CardHeader className="p-4 pb-0"> <CardHeader className="p-4 pb-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<Badge <Badge
variant="outline" variant="outline"
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200"> className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200 flex-shrink-0">
{farm.farmType} {farm.farmType}
</Badge> </Badge>
<div className="flex items-center text-xs text-muted-foreground"> {/* Actions Dropdown */}
<CalendarDays className="h-3 w-3 mr-1" /> <DropdownMenu>
{formattedDate} <DropdownMenuTrigger asChild onClick={stopPropagation}>
</div> <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> </div>
</CardHeader> </CardHeader>
<CardContent className="p-4"> {/* Use div for clickable area if needed, or rely on button */}
<div className="flex items-start gap-3"> <div className="flex-grow cursor-pointer" onClick={onClick}>
<div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center"> <CardContent className="p-4">
<Sprout className="h-5 w-5 text-green-600" /> <div className="flex items-start gap-3">
</div> <div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center bg-muted/40">
<div> <Sprout className="h-5 w-5 text-primary" />
<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>
</div> </div>
<div className="grid grid-cols-2 gap-2 mt-3"> {/* Ensure text truncates */}
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center"> <div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">Area</p> <h3 className="text-lg font-medium mb-1 truncate" title={farm.name}>
<p className="font-medium">{farm.totalSize}</p> {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>
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center"> <div className="grid grid-cols-2 gap-2 mt-3">
<p className="text-xs text-muted-foreground">Crops</p> <div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
<p className="font-medium">{farm.crops ? farm.crops.length : 0}</p> <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>
</div> </div>
</div> </CardContent>
</CardContent> </div>
<CardFooter className="p-4 pt-0"> <CardFooter className="p-4 pt-0 mt-auto">
{" "}
{/* Keep footer outside clickable area */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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" /> View details <ArrowRight className="h-3.5 w-3.5" />
</Button> </Button>
</CardFooter> </CardFooter>

View File

@ -20,11 +20,25 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 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 { FarmCard } from "./farm-card";
import { AddFarmForm } from "./add-farm-form"; import { AddFarmForm } from "./add-farm-form";
import { EditFarmForm } from "./edit-farm-form";
import type { Farm } from "@/types"; 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() { export default function FarmSetupPage() {
const router = useRouter(); const router = useRouter();
@ -33,27 +47,68 @@ export default function FarmSetupPage() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<string>("all"); const [activeFilter, setActiveFilter] = useState<string>("all");
const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest"); 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 { const {
data: farms, // Type is Farm[] now data: farms,
isLoading, isLoading,
isError, isError,
error, error,
} = useQuery<Farm[]>({ } = useQuery<Farm[]>({
// Use Farm[] type
queryKey: ["farms"], queryKey: ["farms"],
queryFn: fetchFarms, queryFn: fetchFarms,
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
const mutation = useMutation({ // --- Create Farm Mutation ---
// Pass the correct type to createFarm const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) => mutationFn: (data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) =>
createFarm(data), createFarm(data),
onSuccess: () => { onSuccess: (newFarm) => {
queryClient.invalidateQueries({ queryKey: ["farms"] }); 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; // 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 || []) const filteredAndSortedFarms = (farms || [])
.filter( .filter(
(farm) => (farm) =>
@ -90,10 +174,6 @@ export default function FarmSetupPage() {
// Get distinct farm types. // Get distinct farm types.
const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.farmType))]; // Use camelCase farmType const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.farmType))]; // Use camelCase farmType
const handleAddFarm = async (data: Partial<Farm>) => {
await mutation.mutateAsync(data);
};
return ( return (
<div className="min-h-screen bg-gradient-to-b"> <div className="min-h-screen bg-gradient-to-b">
<div className="container max-w-7xl p-6 mx-auto"> <div className="container max-w-7xl p-6 mx-auto">
@ -114,7 +194,7 @@ export default function FarmSetupPage() {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </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" /> <Plus className="h-4 w-4" />
Add Farm Add Farm
</Button> </Button>
@ -128,8 +208,9 @@ export default function FarmSetupPage() {
<Badge <Badge
key={type} key={type}
variant={activeFilter === type ? "default" : "outline"} variant={activeFilter === type ? "default" : "outline"}
className={`capitalize cursor-pointer ${ className={`capitalize cursor-pointer rounded-full px-3 py-1 text-sm ${
activeFilter === type ? "bg-green-600" : "hover:bg-green-100" // Made rounded-full
activeFilter === type ? "bg-primary text-primary-foreground" : "hover:bg-accent" // Adjusted colors
}`} }`}
onClick={() => setActiveFilter(type)}> onClick={() => setActiveFilter(type)}>
{type === "all" ? "All Farms" : type} {type === "all" ? "All Farms" : type}
@ -148,25 +229,25 @@ export default function FarmSetupPage() {
<DropdownMenuLabel>Sort by</DropdownMenuLabel> <DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className={sortOrder === "newest" ? "bg-green-50" : ""} className={sortOrder === "newest" ? "bg-accent" : ""} // Use accent for selection
onClick={() => setSortOrder("newest")}> onClick={() => setSortOrder("newest")}>
<Calendar className="h-4 w-4 mr-2" /> <Calendar className="h-4 w-4 mr-2" />
Newest first Newest first
{sortOrder === "newest" && <Check className="h-4 w-4 ml-2" />} {sortOrder === "newest" && <Check className="h-4 w-4 ml-auto" />}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className={sortOrder === "oldest" ? "bg-green-50" : ""} className={sortOrder === "oldest" ? "bg-accent" : ""}
onClick={() => setSortOrder("oldest")}> onClick={() => setSortOrder("oldest")}>
<Calendar className="h-4 w-4 mr-2" /> <Calendar className="h-4 w-4 mr-2" />
Oldest first Oldest first
{sortOrder === "oldest" && <Check className="h-4 w-4 ml-2" />} {sortOrder === "oldest" && <Check className="h-4 w-4 ml-auto" />}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className={sortOrder === "alphabetical" ? "bg-green-50" : ""} className={sortOrder === "alphabetical" ? "bg-accent" : ""}
onClick={() => setSortOrder("alphabetical")}> onClick={() => setSortOrder("alphabetical")}>
<Filter className="h-4 w-4 mr-2" /> <Filter className="h-4 w-4 mr-2" />
Alphabetical Alphabetical
{sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-2" />} {sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-auto" />}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -178,21 +259,40 @@ export default function FarmSetupPage() {
{isError && ( {isError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle> <AlertTitle>Error Loading Farms</AlertTitle>
<AlertDescription>{(error as Error)?.message}</AlertDescription> <AlertDescription>{(error as Error)?.message}</AlertDescription>
</Alert> </Alert>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="flex flex-col items-center justify-center py-12"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" /> {[...Array(4)].map(
<p className="text-muted-foreground">Loading your farms...</p> (
_,
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> </div>
)} )}
{/* Empty state */} {/* Empty state */}
{!isLoading && !isError && filteredAndSortedFarms.length === 0 && ( {!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="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"> <div className="bg-green-100 p-3 rounded-full mb-4">
<Leaf className="h-6 w-6 text-green-600" /> <Leaf className="h-6 w-6 text-green-600" />
@ -204,7 +304,7 @@ export default function FarmSetupPage() {
</p> </p>
) : ( ) : (
<p className="text-muted-foreground text-center max-w-md mb-6"> <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> </p>
)} )}
<Button <Button
@ -212,7 +312,7 @@ export default function FarmSetupPage() {
setSearchQuery(""); setSearchQuery("");
setActiveFilter("all"); setActiveFilter("all");
if (!farms || farms.length === 0) { if (!farms || farms.length === 0) {
setIsDialogOpen(true); setIsAddDialogOpen(true);
} }
}} }}
className="gap-2"> className="gap-2">
@ -232,17 +332,31 @@ export default function FarmSetupPage() {
{!isLoading && !isError && filteredAndSortedFarms.length > 0 && ( {!isLoading && !isError && filteredAndSortedFarms.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence> <AnimatePresence>
<motion.div /* ... */> {/* Add Farm Card */}
<FarmCard variant="add" onClick={() => setIsDialogOpen(true)} /> <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> </motion.div>
{/* Existing Farm Cards */}
{filteredAndSortedFarms.map((farm, index) => ( {filteredAndSortedFarms.map((farm, index) => (
<motion.div <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 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: index * 0.05 }} transition={{ duration: 0.2, delay: index * 0.05 }}
className="col-span-1"> className="col-span-1">
<FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.uuid}`)} /> <FarmCard
variant="farm"
farm={farm}
onClick={() => router.push(`/farms/${farm.uuid}`)}
onEditClick={(e) => openEditDialog(farm, e)} // Pass handler
onDeleteClick={(e) => openDeleteDialog(farm, e)} // Pass handler
/>
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
@ -252,16 +366,57 @@ export default function FarmSetupPage() {
</div> </div>
{/* Add Farm Dialog */} {/* Add Farm Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[800px] md:max-w-[900px] lg:max-w-[1000px] xl:max-w-5xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Add New Farm</DialogTitle> <DialogTitle>Add New Farm</DialogTitle>
<DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription> <DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription>
</DialogHeader> </DialogHeader>
{/* Pass handleAddFarm (which now expects Partial<Farm>) */} <AddFarmForm onSubmit={handleAddFarmSubmit} onCancel={() => setIsAddDialogOpen(false)} />
<AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} />
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
} }