mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 14:04: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) {
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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,6 +471,12 @@ export default function CropDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 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 />
|
<Separator />
|
||||||
{/* Growth Progress */}
|
{/* Growth Progress */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -386,6 +513,8 @@ export default function CropDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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,20 +549,22 @@ 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)",
|
name: "Nitrogen (N)",
|
||||||
value: analytics?.nutrientLevels?.nitrogen,
|
value: analytics.nutrientLevels.nitrogen,
|
||||||
color: "bg-blue-500 dark:bg-blue-700",
|
color: "bg-blue-500 dark:bg-blue-700",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Phosphorus (P)",
|
name: "Phosphorus (P)",
|
||||||
value: analytics?.nutrientLevels?.phosphorus,
|
value: analytics.nutrientLevels.phosphorus,
|
||||||
color: "bg-yellow-500 dark:bg-yellow-700",
|
color: "bg-yellow-500 dark:bg-yellow-700",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Potassium (K)",
|
name: "Potassium (K)",
|
||||||
value: analytics?.nutrientLevels?.potassium,
|
value: analytics.nutrientLevels.potassium,
|
||||||
color: "bg-green-500 dark:bg-green-700",
|
color: "bg-green-500 dark:bg-green-700",
|
||||||
},
|
},
|
||||||
].map((nutrient) => (
|
].map((nutrient) => (
|
||||||
@ -449,8 +580,8 @@ export default function CropDetailPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 "{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>
|
||||||
</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";
|
"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,35 +56,63 @@ 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>
|
||||||
|
{/* Use div for clickable area if needed, or rely on button */}
|
||||||
|
<div className="flex-grow cursor-pointer" onClick={onClick}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center">
|
<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-green-600" />
|
<Sprout className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{/* Ensure text truncates */}
|
||||||
<h3 className="text-xl font-medium mb-1 truncate">{farm.name}</h3>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center text-sm text-muted-foreground mb-2">
|
<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" />
|
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
|
||||||
<span className="truncate">{farm.lat}</span>
|
{/* 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="grid grid-cols-2 gap-2 mt-3">
|
<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">
|
<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="text-xs text-muted-foreground">Area</p>
|
||||||
<p className="font-medium">{farm.totalSize}</p>
|
<p className="font-medium truncate" title={farm.totalSize || "N/A"}>
|
||||||
|
{farm.totalSize || "N/A"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
<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="text-xs text-muted-foreground">Crops</p>
|
||||||
@ -81,11 +122,15 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="p-4 pt-0">
|
</div>
|
||||||
|
<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>
|
||||||
|
|||||||
@ -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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user