mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-18 13:34:08 +01:00
feat: add ability to edit farm and crop
This commit is contained in:
parent
1e6c631be3
commit
644b3f940d
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 "{cropland.name}" 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>
|
||||
);
|
||||
|
||||
242
frontend/app/(sidebar)/farms/edit-farm-form.tsx
Normal file
242
frontend/app/(sidebar)/farms/edit-farm-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user