mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-18 13:34:08 +01:00
Merge pull request #25 from ForFarmTeam/feature-inventory
Fix inventory edit and adding
This commit is contained in:
commit
b6f57da373
@ -2,8 +2,8 @@ root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/api ./cmd/forfarm"
|
||||
bin = "./tmp/api"
|
||||
cmd = "go build -o ./tmp/api.exe ./cmd/forfarm"
|
||||
bin = "./tmp/api.exe"
|
||||
args_bin = ["api"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["assets", "tmp", "vendor"]
|
||||
|
||||
@ -12,8 +12,9 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pressly/goose/v3 v3.24.1
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
|
||||
@ -48,8 +48,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@ -60,6 +58,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@ -67,6 +67,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
|
||||
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
@ -99,6 +101,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
|
||||
134
backend/internal/api/analytic.go
Normal file
134
backend/internal/api/analytic.go
Normal file
@ -0,0 +1,134 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (a *api) registerAnalyticsRoutes(_ chi.Router, api huma.API) {
|
||||
tags := []string{"analytics"}
|
||||
prefix := "/analytics"
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getFarmAnalytics",
|
||||
Method: http.MethodGet,
|
||||
Path: prefix + "/farm/{farmId}", // Changed path param name
|
||||
Tags: tags,
|
||||
Summary: "Get aggregated analytics data for a specific farm",
|
||||
Description: "Retrieves various analytics metrics for a farm, requiring user ownership.",
|
||||
}, a.getFarmAnalyticsHandler)
|
||||
|
||||
// New endpoint for Crop Analytics
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getCropAnalytics",
|
||||
Method: http.MethodGet,
|
||||
Path: prefix + "/crop/{cropId}", // Changed path param name
|
||||
Tags: tags,
|
||||
Summary: "Get analytics data for a specific crop",
|
||||
Description: "Retrieves analytics metrics for a specific crop/cropland, requiring user ownership of the parent farm.",
|
||||
}, a.getCropAnalyticsHandler)
|
||||
}
|
||||
|
||||
type GetFarmAnalyticsInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
FarmID string `path:"farmId" required:"true" doc:"UUID of the farm to get analytics for" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"` // Changed path param name
|
||||
}
|
||||
|
||||
type GetFarmAnalyticsOutput struct {
|
||||
Body domain.FarmAnalytics `json:"body"`
|
||||
}
|
||||
|
||||
// New Input Type for Crop Analytics
|
||||
type GetCropAnalyticsInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
CropID string `path:"cropId" required:"true" doc:"UUID of the crop/cropland to get analytics for" example:"b2c3d4e5-f6a7-8901-2345-67890abcdef1"` // Changed path param name
|
||||
}
|
||||
|
||||
// New Output Type for Crop Analytics
|
||||
type GetCropAnalyticsOutput struct {
|
||||
Body domain.CropAnalytics `json:"body"`
|
||||
}
|
||||
|
||||
func (a *api) getFarmAnalyticsHandler(ctx context.Context, input *GetFarmAnalyticsInput) (*GetFarmAnalyticsOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed: " + err.Error())
|
||||
}
|
||||
|
||||
if _, err := uuid.Parse(input.FarmID); err != nil {
|
||||
return nil, huma.Error400BadRequest("Invalid Farm ID format.")
|
||||
}
|
||||
|
||||
analyticsData, err := a.analyticsRepo.GetFarmAnalytics(ctx, input.FarmID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
a.logger.Info("Analytics data not found for farm", "farm_id", input.FarmID)
|
||||
return nil, huma.Error404NotFound("Analytics data not found for this farm.")
|
||||
}
|
||||
a.logger.Error("Failed to retrieve farm analytics", "farm_id", input.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve analytics data.")
|
||||
}
|
||||
|
||||
// Authorization Check: User must own the farm
|
||||
if analyticsData.OwnerID != userID {
|
||||
a.logger.Warn("User attempted to access analytics for farm they do not own", "user_id", userID, "farm_id", input.FarmID, "owner_id", analyticsData.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to view analytics for this farm.")
|
||||
}
|
||||
|
||||
resp := &GetFarmAnalyticsOutput{
|
||||
Body: *analyticsData,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// New Handler for Crop Analytics
|
||||
func (a *api) getCropAnalyticsHandler(ctx context.Context, input *GetCropAnalyticsInput) (*GetCropAnalyticsOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed: " + err.Error())
|
||||
}
|
||||
|
||||
if _, err := uuid.Parse(input.CropID); err != nil {
|
||||
return nil, huma.Error400BadRequest("Invalid Crop ID format.")
|
||||
}
|
||||
|
||||
// Fetch Crop Analytics Data
|
||||
cropAnalytics, err := a.analyticsRepo.GetCropAnalytics(ctx, input.CropID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
a.logger.Info("Crop analytics data not found", "crop_id", input.CropID)
|
||||
return nil, huma.Error404NotFound("Crop analytics data not found.")
|
||||
}
|
||||
a.logger.Error("Failed to retrieve crop analytics", "crop_id", input.CropID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve crop analytics data.")
|
||||
}
|
||||
|
||||
// Authorization Check: Verify user owns the farm this crop belongs to
|
||||
farm, err := a.farmRepo.GetByID(ctx, cropAnalytics.FarmID)
|
||||
if err != nil {
|
||||
// This case is less likely if cropAnalytics was found, but handle defensively
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
a.logger.Error("Farm associated with crop not found", "farm_id", cropAnalytics.FarmID, "crop_id", input.CropID)
|
||||
return nil, huma.Error404NotFound("Associated farm not found.")
|
||||
}
|
||||
a.logger.Error("Failed to retrieve farm for authorization check", "farm_id", cropAnalytics.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to verify ownership.")
|
||||
}
|
||||
|
||||
if farm.OwnerID != userID {
|
||||
a.logger.Warn("User attempted to access analytics for crop on farm they do not own", "user_id", userID, "crop_id", input.CropID, "farm_id", cropAnalytics.FarmID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to view analytics for this crop.")
|
||||
}
|
||||
|
||||
// Return the fetched data
|
||||
resp := &GetCropAnalyticsOutput{
|
||||
Body: *cropAnalytics,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@ -2,9 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
||||
@ -13,14 +16,18 @@ import (
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/forfarm/backend/internal/config"
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
m "github.com/forfarm/backend/internal/middlewares"
|
||||
"github.com/forfarm/backend/internal/repository"
|
||||
"github.com/forfarm/backend/internal/services/weather"
|
||||
"github.com/forfarm/backend/internal/utilities"
|
||||
)
|
||||
|
||||
type api struct {
|
||||
logger *slog.Logger
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
httpClient *http.Client
|
||||
eventPublisher domain.EventPublisher
|
||||
|
||||
userRepo domain.UserRepository
|
||||
cropRepo domain.CroplandRepository
|
||||
@ -28,35 +35,76 @@ type api struct {
|
||||
plantRepo domain.PlantRepository
|
||||
inventoryRepo domain.InventoryRepository
|
||||
harvestRepo domain.HarvestRepository
|
||||
knowledgeHubRepo domain.KnowledgeHubRepository
|
||||
analyticsRepo domain.AnalyticsRepository
|
||||
knowledgeHubRepo domain.KnowledgeHubRepository
|
||||
|
||||
weatherFetcher domain.WeatherFetcher
|
||||
}
|
||||
|
||||
func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api {
|
||||
var weatherFetcherInstance domain.WeatherFetcher
|
||||
|
||||
func GetWeatherFetcher() domain.WeatherFetcher {
|
||||
return weatherFetcherInstance
|
||||
}
|
||||
|
||||
func NewAPI(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
pool *pgxpool.Pool,
|
||||
eventPublisher domain.EventPublisher,
|
||||
analyticsRepo domain.AnalyticsRepository,
|
||||
inventoryRepo domain.InventoryRepository,
|
||||
croplandRepo domain.CroplandRepository,
|
||||
farmRepo domain.FarmRepository,
|
||||
) *api {
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
userRepository := repository.NewPostgresUser(pool)
|
||||
croplandRepository := repository.NewPostgresCropland(pool)
|
||||
farmRepository := repository.NewPostgresFarm(pool)
|
||||
plantRepository := repository.NewPostgresPlant(pool)
|
||||
knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool)
|
||||
inventoryRepository := repository.NewPostgresInventory(pool)
|
||||
harvestRepository := repository.NewPostgresHarvest(pool)
|
||||
|
||||
owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger)
|
||||
cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid OPENWEATHER_CACHE_TTL format, using default 15m", "value", config.OPENWEATHER_CACHE_TTL, "error", err)
|
||||
cacheTTL = 15 * time.Minute
|
||||
}
|
||||
cleanupInterval := cacheTTL * 2
|
||||
if cleanupInterval < 5*time.Minute {
|
||||
cleanupInterval = 5 * time.Minute
|
||||
}
|
||||
cachedWeatherFetcher := weather.NewCachedWeatherFetcher(owmFetcher, cacheTTL, cleanupInterval, logger)
|
||||
weatherFetcherInstance = cachedWeatherFetcher
|
||||
|
||||
return &api{
|
||||
logger: logger,
|
||||
httpClient: client,
|
||||
logger: logger,
|
||||
httpClient: client,
|
||||
eventPublisher: eventPublisher,
|
||||
|
||||
userRepo: userRepository,
|
||||
cropRepo: croplandRepository,
|
||||
farmRepo: farmRepository,
|
||||
cropRepo: croplandRepo,
|
||||
farmRepo: farmRepo,
|
||||
plantRepo: plantRepository,
|
||||
inventoryRepo: inventoryRepository,
|
||||
inventoryRepo: inventoryRepo,
|
||||
harvestRepo: harvestRepository,
|
||||
knowledgeHubRepo: knowledgeHubRepository,
|
||||
analyticsRepo: analyticsRepo,
|
||||
knowledgeHubRepo: knowledgeHubRepository,
|
||||
weatherFetcher: cachedWeatherFetcher,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *api) getUserIDFromHeader(authHeader string) (string, error) {
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return "", errors.New("invalid authorization header")
|
||||
}
|
||||
tokenString := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
return utilities.ExtractUUIDFromToken(tokenString)
|
||||
}
|
||||
|
||||
func (a *api) Server(port int) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
@ -69,7 +117,7 @@ func (a *api) Routes() *chi.Mux {
|
||||
|
||||
router.Use(cors.Handler(cors.Options{
|
||||
// AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
|
||||
AllowedOrigins: []string{"https://*", "http://*"},
|
||||
AllowedOrigins: []string{"https://*", "http://*", "http://localhost:3000"},
|
||||
// AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
@ -85,8 +133,9 @@ func (a *api) Routes() *chi.Mux {
|
||||
a.registerAuthRoutes(r, api)
|
||||
a.registerCropRoutes(r, api)
|
||||
a.registerPlantRoutes(r, api)
|
||||
a.registerOauthRoutes(r, api)
|
||||
a.registerKnowledgeHubRoutes(r, api)
|
||||
a.registerOauthRoutes(r, api)
|
||||
a.registerInventoryRoutes(r, api)
|
||||
})
|
||||
|
||||
router.Group(func(r chi.Router) {
|
||||
@ -94,7 +143,7 @@ func (a *api) Routes() *chi.Mux {
|
||||
a.registerHelloRoutes(r, api)
|
||||
a.registerFarmRoutes(r, api)
|
||||
a.registerUserRoutes(r, api)
|
||||
a.registerInventoryRoutes(r, api)
|
||||
a.registerAnalyticsRoutes(r, api)
|
||||
})
|
||||
|
||||
return router
|
||||
|
||||
@ -55,6 +55,7 @@ type RegisterInput struct {
|
||||
|
||||
type RegisterOutput struct {
|
||||
Body struct {
|
||||
// Use camelCase for JSON tags
|
||||
Token string `json:"token" example:"JWT token for the user"`
|
||||
}
|
||||
}
|
||||
@ -85,73 +86,97 @@ func (a *api) registerHandler(ctx context.Context, input *RegisterInput) (*Regis
|
||||
}
|
||||
|
||||
if err := validateEmail(input.Body.Email); err != nil {
|
||||
return nil, err
|
||||
// Return validation error in a structured way if Huma supports it, otherwise basic error
|
||||
return nil, huma.Error422UnprocessableEntity("Validation failed", err)
|
||||
}
|
||||
if err := validatePassword(input.Body.Password); err != nil {
|
||||
return nil, err
|
||||
return nil, huma.Error422UnprocessableEntity("Validation failed", err)
|
||||
}
|
||||
|
||||
_, err := a.userRepo.GetByEmail(ctx, input.Body.Email)
|
||||
if err == domain.ErrNotFound {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Body.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Check if the error is specifically ErrNotFound
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
hashedPassword, hashErr := bcrypt.GenerateFromPassword([]byte(input.Body.Password), bcrypt.DefaultCost)
|
||||
if hashErr != nil {
|
||||
a.logger.Error("Failed to hash password during registration", "error", hashErr)
|
||||
return nil, huma.Error500InternalServerError("Registration failed due to internal error")
|
||||
}
|
||||
|
||||
err = a.userRepo.CreateOrUpdate(ctx, &domain.User{
|
||||
newUser := &domain.User{
|
||||
Email: input.Body.Email,
|
||||
Password: string(hashedPassword),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
user, err := a.userRepo.GetByEmail(ctx, input.Body.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
createErr := a.userRepo.CreateOrUpdate(ctx, newUser)
|
||||
if createErr != nil {
|
||||
a.logger.Error("Failed to create user", "email", input.Body.Email, "error", createErr)
|
||||
// Check for specific database errors if needed (e.g., unique constraint violation)
|
||||
return nil, huma.Error500InternalServerError("Failed to register user")
|
||||
}
|
||||
|
||||
token, err := utilities.CreateJwtToken(user.UUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
token, tokenErr := utilities.CreateJwtToken(newUser.UUID)
|
||||
if tokenErr != nil {
|
||||
a.logger.Error("Failed to create JWT token after registration", "user_uuid", newUser.UUID, "error", tokenErr)
|
||||
// Consider how to handle this - user is created but can't log in immediately.
|
||||
// Maybe log the error and return success but without a token? Or return an error.
|
||||
return nil, huma.Error500InternalServerError("Registration partially succeeded, but failed to generate token")
|
||||
}
|
||||
|
||||
resp.Body.Token = token
|
||||
return resp, nil
|
||||
} else if err == nil {
|
||||
return nil, huma.Error409Conflict("User with this email already exists")
|
||||
} else {
|
||||
// Other database error occurred during GetByEmail
|
||||
a.logger.Error("Database error checking user email", "email", input.Body.Email, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to check user existence")
|
||||
}
|
||||
|
||||
return nil, errors.New("user already exists")
|
||||
}
|
||||
|
||||
func (a *api) loginHandler(ctx context.Context, input *LoginInput) (*LoginOutput, error) {
|
||||
resp := &LoginOutput{}
|
||||
|
||||
if input == nil {
|
||||
return nil, errors.New("invalid input")
|
||||
return nil, huma.Error400BadRequest("Invalid input: missing request body")
|
||||
}
|
||||
if input.Body.Email == "" {
|
||||
return nil, errors.New("email field is required")
|
||||
return nil, huma.Error400BadRequest("Email field is required")
|
||||
}
|
||||
if input.Body.Password == "" {
|
||||
return nil, errors.New("password field is required")
|
||||
return nil, huma.Error400BadRequest("Password field is required")
|
||||
}
|
||||
|
||||
if err := validateEmail(input.Body.Email); err != nil {
|
||||
return nil, err
|
||||
return nil, huma.Error422UnprocessableEntity("Validation failed", err)
|
||||
}
|
||||
|
||||
user, err := a.userRepo.GetByEmail(ctx, input.Body.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
a.logger.Warn("Login attempt for non-existent user", "email", input.Body.Email)
|
||||
return nil, huma.Error401Unauthorized("Invalid email or password") // Generic error for security
|
||||
}
|
||||
a.logger.Error("Database error during login lookup", "email", input.Body.Email, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Login failed due to an internal error")
|
||||
}
|
||||
|
||||
// Check if the user is active
|
||||
if !user.IsActive {
|
||||
a.logger.Warn("Login attempt for inactive user", "email", input.Body.Email, "user_uuid", user.UUID)
|
||||
return nil, huma.Error403Forbidden("Account is inactive")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Body.Password)); err != nil {
|
||||
return nil, err
|
||||
a.logger.Warn("Incorrect password attempt", "email", input.Body.Email, "user_uuid", user.UUID)
|
||||
// Do not differentiate between wrong email and wrong password for security
|
||||
return nil, huma.Error401Unauthorized("Invalid email or password")
|
||||
}
|
||||
|
||||
token, err := utilities.CreateJwtToken(user.UUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
a.logger.Error("Failed to create JWT token during login", "user_uuid", user.UUID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to generate login token")
|
||||
}
|
||||
|
||||
resp.Body.Token = token
|
||||
|
||||
@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@ -11,7 +13,6 @@ import (
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
// Register the crop routes
|
||||
func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
|
||||
tags := []string{"crop"}
|
||||
|
||||
@ -37,7 +38,7 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getAllCroplandsByFarmID",
|
||||
Method: http.MethodGet,
|
||||
Path: prefix + "/farm/{farm_id}",
|
||||
Path: prefix + "/farm/{farmId}",
|
||||
Tags: tags,
|
||||
}, a.getAllCroplandsByFarmIDHandler)
|
||||
|
||||
@ -50,117 +51,172 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
|
||||
}, a.createOrUpdateCroplandHandler)
|
||||
}
|
||||
|
||||
// Response structure for all croplands
|
||||
type GetCroplandsOutput struct {
|
||||
Body struct {
|
||||
Croplands []domain.Cropland `json:"croplands"`
|
||||
} `json:"body"`
|
||||
}
|
||||
}
|
||||
|
||||
// Response structure for single cropland by ID
|
||||
type GetCroplandByIDOutput struct {
|
||||
Body struct {
|
||||
Cropland domain.Cropland `json:"cropland"`
|
||||
} `json:"body"`
|
||||
}
|
||||
}
|
||||
|
||||
// Request structure for creating or updating a cropland
|
||||
type CreateOrUpdateCroplandInput struct {
|
||||
Body struct {
|
||||
UUID string `json:"uuid,omitempty"` // Optional for create, required for update
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
LandSize float64 `json:"land_size"`
|
||||
GrowthStage string `json:"growth_stage"`
|
||||
PlantID string `json:"plant_id"`
|
||||
FarmID string `json:"farm_id"`
|
||||
} `json:"body"`
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
Body struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
LandSize float64 `json:"landSize"`
|
||||
GrowthStage string `json:"growthStage"`
|
||||
PlantID string `json:"plantId"`
|
||||
FarmID string `json:"farmId"`
|
||||
GeoFeature json.RawMessage `json:"geoFeature,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
// Response structure for creating or updating a cropland
|
||||
type CreateOrUpdateCroplandOutput struct {
|
||||
Body struct {
|
||||
Cropland domain.Cropland `json:"cropland"`
|
||||
} `json:"body"`
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllCroplands handles GET /crop endpoint
|
||||
func (a *api) getAllCroplandsHandler(ctx context.Context, input *struct{}) (*GetCroplandsOutput, error) {
|
||||
// --- Handlers ---
|
||||
|
||||
func (a *api) getAllCroplandsHandler(ctx context.Context, input *struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
}) (*GetCroplandsOutput, error) {
|
||||
// Note: This currently fetches ALL croplands. Might need owner filtering later.
|
||||
// For now, ensure authentication happens.
|
||||
_, err := a.getUserIDFromHeader(input.Header) // Verify token
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
resp := &GetCroplandsOutput{}
|
||||
|
||||
// Fetch all croplands without filtering by farmID
|
||||
croplands, err := a.cropRepo.GetAll(ctx) // Use the GetAll method
|
||||
croplands, err := a.cropRepo.GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
a.logger.Error("Failed to get all croplands", "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve croplands")
|
||||
}
|
||||
|
||||
resp.Body.Croplands = croplands
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetCroplandByID handles GET /crop/{uuid} endpoint
|
||||
func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct {
|
||||
UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"`
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"`
|
||||
}) (*GetCroplandByIDOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header) // Verify token and get user ID
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
resp := &GetCroplandByIDOutput{}
|
||||
|
||||
// Validate the UUID format
|
||||
if input.UUID == "" {
|
||||
return nil, huma.Error400BadRequest("UUID parameter is required")
|
||||
}
|
||||
|
||||
// Check if the UUID is in a valid format
|
||||
_, err := uuid.FromString(input.UUID)
|
||||
_, err = uuid.FromString(input.UUID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid UUID format")
|
||||
return nil, huma.Error400BadRequest("Invalid UUID format")
|
||||
}
|
||||
|
||||
// Fetch cropland by ID
|
||||
cropland, err := a.cropRepo.GetByID(ctx, input.UUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return nil, huma.Error404NotFound("cropland not found")
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Warn("Cropland not found", "croplandId", input.UUID, "requestingUserId", userID)
|
||||
return nil, huma.Error404NotFound("Cropland not found")
|
||||
}
|
||||
return nil, err
|
||||
a.logger.Error("Failed to get cropland by ID", "croplandId", input.UUID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve cropland")
|
||||
}
|
||||
|
||||
// Authorization check: User must own the farm this cropland belongs to
|
||||
farm, err := a.farmRepo.GetByID(ctx, cropland.FarmID) // Fetch the farm
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Error("Farm associated with cropland not found", "farmId", cropland.FarmID, "croplandId", input.UUID)
|
||||
// This indicates a data integrity issue if the cropland exists but farm doesn't
|
||||
return nil, huma.Error404NotFound("Associated farm not found for cropland")
|
||||
}
|
||||
a.logger.Error("Failed to fetch farm for cropland authorization", "farmId", cropland.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to verify ownership")
|
||||
}
|
||||
|
||||
if farm.OwnerID != userID {
|
||||
a.logger.Warn("Unauthorized attempt to access cropland", "croplandId", input.UUID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to view this cropland")
|
||||
}
|
||||
|
||||
resp.Body.Cropland = cropland
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetAllCroplandsByFarmID handles GET /crop/farm/{farm_id} endpoint
|
||||
func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct {
|
||||
FarmID string `path:"farm_id" example:"550e8400-e29b-41d4-a716-446655440000"`
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
FarmID string `path:"farmId" example:"550e8400-e29b-41d4-a716-446655440000"`
|
||||
}) (*GetCroplandsOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
resp := &GetCroplandsOutput{}
|
||||
|
||||
// Validate the FarmID format
|
||||
if input.FarmID == "" {
|
||||
return nil, huma.Error400BadRequest("FarmID parameter is required")
|
||||
return nil, huma.Error400BadRequest("farm_id parameter is required")
|
||||
}
|
||||
|
||||
// Check if the FarmID is in a valid format
|
||||
_, err := uuid.FromString(input.FarmID)
|
||||
farmUUID, err := uuid.FromString(input.FarmID)
|
||||
if err != nil {
|
||||
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())
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Warn("Attempt to get crops for non-existent farm", "farmId", input.FarmID, "requestingUserId", userID)
|
||||
return nil, huma.Error404NotFound("Farm not found")
|
||||
}
|
||||
a.logger.Error("Failed to fetch farm for cropland list authorization", "farmId", input.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to verify ownership")
|
||||
}
|
||||
if farm.OwnerID != userID {
|
||||
a.logger.Warn("Unauthorized attempt to list crops for farm", "farmId", input.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to view crops for this farm")
|
||||
}
|
||||
|
||||
// Fetch croplands by FarmID
|
||||
croplands, err := a.cropRepo.GetByFarmID(ctx, input.FarmID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
a.logger.Error("Failed to get croplands by farm ID", "farmId", input.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve croplands for farm")
|
||||
}
|
||||
|
||||
if croplands == nil {
|
||||
croplands = []domain.Cropland{}
|
||||
}
|
||||
|
||||
resp.Body.Croplands = croplands
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdateCropland handles POST /crop endpoint
|
||||
func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOrUpdateCroplandInput) (*CreateOrUpdateCroplandOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
resp := &CreateOrUpdateCroplandOutput{}
|
||||
|
||||
// Validate required fields
|
||||
// --- Input Validation ---
|
||||
if input.Body.Name == "" {
|
||||
return nil, huma.Error400BadRequest("name is required")
|
||||
}
|
||||
@ -168,24 +224,65 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr
|
||||
return nil, huma.Error400BadRequest("status is required")
|
||||
}
|
||||
if input.Body.GrowthStage == "" {
|
||||
return nil, huma.Error400BadRequest("growth_stage is required")
|
||||
return nil, huma.Error400BadRequest("growthStage is required")
|
||||
}
|
||||
if input.Body.PlantID == "" {
|
||||
return nil, huma.Error400BadRequest("plant_id is required")
|
||||
return nil, huma.Error400BadRequest("plantId is required")
|
||||
}
|
||||
if input.Body.FarmID == "" {
|
||||
return nil, huma.Error400BadRequest("farm_id is required")
|
||||
return nil, huma.Error400BadRequest("farmId is required")
|
||||
}
|
||||
|
||||
// Validate UUID if provided
|
||||
// Validate UUID formats
|
||||
if input.Body.UUID != "" {
|
||||
_, err := uuid.FromString(input.Body.UUID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid UUID format")
|
||||
if _, err := uuid.FromString(input.Body.UUID); err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid cropland UUID format")
|
||||
}
|
||||
}
|
||||
if _, err := uuid.FromString(input.Body.PlantID); err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid plantId UUID format")
|
||||
}
|
||||
farmUUID, err := uuid.FromString(input.Body.FarmID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid farm_id UUID format")
|
||||
}
|
||||
|
||||
// Validate JSON format if GeoFeature is provided
|
||||
if input.Body.GeoFeature != nil && !json.Valid(input.Body.GeoFeature) {
|
||||
return nil, huma.Error400BadRequest("invalid JSON format for geoFeature")
|
||||
}
|
||||
|
||||
// --- Authorization Check ---
|
||||
// User must own the farm they are adding/updating a crop for
|
||||
farm, err := a.farmRepo.GetByID(ctx, farmUUID.String())
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Warn("Attempt to create/update crop for non-existent farm", "farmId", input.Body.FarmID, "requestingUserId", userID)
|
||||
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)
|
||||
return nil, huma.Error500InternalServerError("Failed to verify ownership")
|
||||
}
|
||||
if farm.OwnerID != userID {
|
||||
a.logger.Warn("Unauthorized attempt to create/update crop on farm", "farmId", input.Body.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to modify crops on this farm")
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// Map input to domain.Cropland
|
||||
// --- Prepare and Save Cropland ---
|
||||
cropland := &domain.Cropland{
|
||||
UUID: input.Body.UUID,
|
||||
Name: input.Body.Name,
|
||||
@ -195,15 +292,18 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr
|
||||
GrowthStage: input.Body.GrowthStage,
|
||||
PlantID: input.Body.PlantID,
|
||||
FarmID: input.Body.FarmID,
|
||||
GeoFeature: input.Body.GeoFeature,
|
||||
}
|
||||
|
||||
// Create or update the cropland
|
||||
err := a.cropRepo.CreateOrUpdate(ctx, cropland)
|
||||
// Use the repository's CreateOrUpdate which handles assigning UUID if needed
|
||||
err = a.cropRepo.CreateOrUpdate(ctx, cropland)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
a.logger.Error("Failed to save cropland to database", "farm_id", input.Body.FarmID, "plantId", input.Body.PlantID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to save cropland")
|
||||
}
|
||||
|
||||
// Return the created/updated cropland
|
||||
a.logger.Info("Cropland created/updated successfully", "croplandId", cropland.UUID, "farmId", cropland.FarmID)
|
||||
|
||||
resp.Body.Cropland = *cropland
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@ -2,6 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
@ -9,9 +12,24 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// registerFarmRoutes defines endpoints for farm operations.
|
||||
func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) {
|
||||
tags := []string{"farm"}
|
||||
prefix := "/farm"
|
||||
prefix := "/farms"
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getAllFarms",
|
||||
Method: http.MethodGet,
|
||||
Path: prefix,
|
||||
Tags: tags,
|
||||
}, a.getAllFarmsHandler)
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getFarmByID",
|
||||
Method: http.MethodGet,
|
||||
Path: prefix + "/{farmId}",
|
||||
Tags: tags,
|
||||
}, a.getFarmByIDHandler)
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createFarm",
|
||||
@ -21,34 +39,32 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) {
|
||||
}, a.createFarmHandler)
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getFarmsByOwner",
|
||||
Method: http.MethodGet,
|
||||
Path: prefix + "/owner/{owner_id}",
|
||||
OperationID: "updateFarm",
|
||||
Method: http.MethodPut,
|
||||
Path: prefix + "/{farmId}",
|
||||
Tags: tags,
|
||||
}, a.getFarmsByOwnerHandler)
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getFarmByID",
|
||||
Method: http.MethodGet,
|
||||
Path: prefix + "/{farm_id}",
|
||||
Tags: tags,
|
||||
}, a.getFarmByIDHandler)
|
||||
}, a.updateFarmHandler)
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "deleteFarm",
|
||||
Method: http.MethodDelete,
|
||||
Path: prefix + "/{farm_id}",
|
||||
Path: prefix + "/{farmId}",
|
||||
Tags: tags,
|
||||
}, a.deleteFarmHandler)
|
||||
}
|
||||
|
||||
//
|
||||
// Input and Output types
|
||||
//
|
||||
|
||||
type CreateFarmInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
Body struct {
|
||||
Name string `json:"name"`
|
||||
Lat []float64 `json:"lat"`
|
||||
Lon []float64 `json:"lon"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Lat float64 `json:"lat" required:"true"`
|
||||
Lon float64 `json:"lon" required:"true"`
|
||||
FarmType string `json:"farmType,omitempty"`
|
||||
TotalSize string `json:"totalSize,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,63 +74,42 @@ type CreateFarmOutput struct {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*CreateFarmOutput, error) {
|
||||
farm := &domain.Farm{
|
||||
Name: input.Body.Name,
|
||||
Lat: input.Body.Lat,
|
||||
Lon: input.Body.Lon,
|
||||
OwnerID: input.Body.OwnerID,
|
||||
}
|
||||
|
||||
err := a.farmRepo.CreateOrUpdate(ctx, farm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &CreateFarmOutput{Body: struct {
|
||||
UUID string `json:"uuid"`
|
||||
}{UUID: farm.UUID}}, nil
|
||||
type GetAllFarmsInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
}
|
||||
|
||||
type GetFarmsByOwnerInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
OwnerID string `path:"owner_id"`
|
||||
}
|
||||
|
||||
type GetFarmsByOwnerOutput struct {
|
||||
Body []domain.Farm
|
||||
}
|
||||
|
||||
func (a *api) getFarmsByOwnerHandler(ctx context.Context, input *GetFarmsByOwnerInput) (*GetFarmsByOwnerOutput, error) {
|
||||
farms, err := a.farmRepo.GetByOwnerID(ctx, input.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GetFarmsByOwnerOutput{Body: farms}, nil
|
||||
type GetAllFarmsOutput struct {
|
||||
Body []domain.Farm `json:"farms"`
|
||||
}
|
||||
|
||||
type GetFarmByIDInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
FarmID string `path:"farm_id"`
|
||||
FarmID string `path:"farmId" required:"true"`
|
||||
}
|
||||
|
||||
type GetFarmByIDOutput struct {
|
||||
Body domain.Farm
|
||||
Body domain.Farm `json:"farm"`
|
||||
}
|
||||
|
||||
func (a *api) getFarmByIDHandler(ctx context.Context, input *GetFarmByIDInput) (*GetFarmByIDOutput, error) {
|
||||
farm, err := a.farmRepo.GetByID(ctx, input.FarmID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
type UpdateFarmInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
FarmID string `path:"farmId" required:"true"`
|
||||
Body struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
FarmType *string `json:"farmType,omitempty"`
|
||||
TotalSize *string `json:"totalSize,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
return &GetFarmByIDOutput{Body: farm}, nil
|
||||
type UpdateFarmOutput struct {
|
||||
Body domain.Farm `json:"farm"`
|
||||
}
|
||||
|
||||
type DeleteFarmInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
FarmID string `path:"farm_id"`
|
||||
FarmID string `path:"farmId" required:"true"`
|
||||
}
|
||||
|
||||
type DeleteFarmOutput struct {
|
||||
@ -123,13 +118,196 @@ type DeleteFarmOutput struct {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *api) deleteFarmHandler(ctx context.Context, input *DeleteFarmInput) (*DeleteFarmOutput, error) {
|
||||
err := a.farmRepo.Delete(ctx, input.FarmID)
|
||||
//
|
||||
// API Handlers
|
||||
//
|
||||
|
||||
func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*CreateFarmOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
return &DeleteFarmOutput{Body: struct {
|
||||
Message string `json:"message"`
|
||||
}{Message: "Farm deleted successfully"}}, nil
|
||||
farm := &domain.Farm{
|
||||
Name: input.Body.Name,
|
||||
Lat: input.Body.Lat,
|
||||
Lon: input.Body.Lon,
|
||||
FarmType: input.Body.FarmType,
|
||||
TotalSize: input.Body.TotalSize,
|
||||
OwnerID: userID,
|
||||
}
|
||||
|
||||
// Validate the farm object (optional but recommended)
|
||||
// if err := farm.Validate(); err != nil {
|
||||
// return nil, huma.Error422UnprocessableEntity("Validation failed", err)
|
||||
// }
|
||||
|
||||
fmt.Println("Creating farm:", farm) // Keep for debugging if needed
|
||||
|
||||
if err := a.farmRepo.CreateOrUpdate(ctx, farm); err != nil {
|
||||
a.logger.Error("Failed to create farm in database", "error", err, "ownerId", userID, "farmName", farm.Name)
|
||||
return nil, huma.Error500InternalServerError("Failed to create farm")
|
||||
}
|
||||
|
||||
a.logger.Info("Farm created successfully", "farmId", farm.UUID, "ownerId", userID)
|
||||
|
||||
return &CreateFarmOutput{
|
||||
Body: struct {
|
||||
UUID string `json:"uuid"`
|
||||
}{UUID: farm.UUID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *api) getAllFarmsHandler(ctx context.Context, input *GetAllFarmsInput) (*GetAllFarmsOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
farms, err := a.farmRepo.GetByOwnerID(ctx, userID)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to get farms by owner ID", "ownerId", userID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve farms")
|
||||
}
|
||||
|
||||
// Handle case where user has no farms (return empty list, not error)
|
||||
if farms == nil {
|
||||
farms = []domain.Farm{}
|
||||
}
|
||||
|
||||
return &GetAllFarmsOutput{Body: farms}, nil
|
||||
}
|
||||
|
||||
func (a *api) getFarmByIDHandler(ctx context.Context, input *GetFarmByIDInput) (*GetFarmByIDOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
farm, err := a.farmRepo.GetByID(ctx, input.FarmID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { // Handle pgx ErrNoRows too
|
||||
a.logger.Warn("Farm not found", "farmId", input.FarmID, "requestingUserId", userID)
|
||||
return nil, huma.Error404NotFound("Farm not found")
|
||||
}
|
||||
a.logger.Error("Failed to get farm by ID", "farmId", input.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve farm")
|
||||
}
|
||||
|
||||
if farm.OwnerID != userID {
|
||||
a.logger.Warn("Unauthorized attempt to access farm", "farmId", input.FarmID, "requestingUserId", userID, "ownerId", farm.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to view this farm")
|
||||
}
|
||||
|
||||
return &GetFarmByIDOutput{Body: *farm}, nil
|
||||
}
|
||||
|
||||
func (a *api) updateFarmHandler(ctx context.Context, input *UpdateFarmInput) (*UpdateFarmOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
farm, err := a.farmRepo.GetByID(ctx, input.FarmID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Warn("Attempt to update non-existent farm", "farmId", input.FarmID, "requestingUserId", userID)
|
||||
return nil, huma.Error404NotFound("Farm not found")
|
||||
}
|
||||
a.logger.Error("Failed to get farm for update", "farmId", input.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve farm for update")
|
||||
}
|
||||
|
||||
if farm.OwnerID != userID {
|
||||
a.logger.Warn("Unauthorized attempt to update farm", "farmId", input.FarmID, "requestingUserId", userID, "ownerId", farm.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to update this farm")
|
||||
}
|
||||
|
||||
// Apply updates selectively
|
||||
updated := false
|
||||
if input.Body.Name != nil && *input.Body.Name != "" && *input.Body.Name != farm.Name {
|
||||
farm.Name = *input.Body.Name
|
||||
updated = true
|
||||
}
|
||||
if input.Body.Lat != nil && *input.Body.Lat != farm.Lat {
|
||||
farm.Lat = *input.Body.Lat
|
||||
updated = true
|
||||
}
|
||||
if input.Body.Lon != nil && *input.Body.Lon != farm.Lon {
|
||||
farm.Lon = *input.Body.Lon
|
||||
updated = true
|
||||
}
|
||||
if input.Body.FarmType != nil && *input.Body.FarmType != farm.FarmType {
|
||||
farm.FarmType = *input.Body.FarmType
|
||||
updated = true
|
||||
}
|
||||
if input.Body.TotalSize != nil && *input.Body.TotalSize != farm.TotalSize {
|
||||
farm.TotalSize = *input.Body.TotalSize
|
||||
updated = true
|
||||
}
|
||||
|
||||
if !updated {
|
||||
a.logger.Info("No changes detected for farm update", "farmId", input.FarmID)
|
||||
// Return the existing farm data as no update was needed
|
||||
return &UpdateFarmOutput{Body: *farm}, nil
|
||||
}
|
||||
|
||||
// Validate updated farm object (optional but recommended)
|
||||
// if err := farm.Validate(); err != nil {
|
||||
// return nil, huma.Error422UnprocessableEntity("Validation failed after update", err)
|
||||
// }
|
||||
|
||||
if err = a.farmRepo.CreateOrUpdate(ctx, farm); err != nil {
|
||||
a.logger.Error("Failed to update farm in database", "farmId", input.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to update farm")
|
||||
}
|
||||
|
||||
a.logger.Info("Farm updated successfully", "farmId", farm.UUID, "ownerId", userID)
|
||||
|
||||
// Fetch the updated farm again to ensure we return the latest state (including UpdatedAt)
|
||||
updatedFarm, fetchErr := a.farmRepo.GetByID(ctx, input.FarmID)
|
||||
if fetchErr != nil {
|
||||
a.logger.Error("Failed to fetch farm after update", "farmId", input.FarmID, "error", fetchErr)
|
||||
// Return the potentially stale data from 'farm' as a fallback, but log the error
|
||||
return &UpdateFarmOutput{Body: *farm}, nil
|
||||
}
|
||||
|
||||
return &UpdateFarmOutput{Body: *updatedFarm}, nil
|
||||
}
|
||||
|
||||
func (a *api) deleteFarmHandler(ctx context.Context, input *DeleteFarmInput) (*DeleteFarmOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
farm, err := a.farmRepo.GetByID(ctx, input.FarmID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Warn("Attempt to delete non-existent farm", "farmId", input.FarmID, "requestingUserId", userID)
|
||||
// Consider returning 204 No Content if delete is idempotent
|
||||
return nil, huma.Error404NotFound("Farm not found")
|
||||
}
|
||||
a.logger.Error("Failed to get farm for deletion", "farmId", input.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve farm for deletion")
|
||||
}
|
||||
|
||||
if farm.OwnerID != userID {
|
||||
a.logger.Warn("Unauthorized attempt to delete farm", "farmId", input.FarmID, "requestingUserId", userID, "ownerId", farm.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to delete this farm")
|
||||
}
|
||||
|
||||
if err := a.farmRepo.Delete(ctx, input.FarmID); err != nil {
|
||||
a.logger.Error("Failed to delete farm from database", "farmId", input.FarmID, "error", err)
|
||||
// Consider potential FK constraint errors if crops aren't deleted automatically
|
||||
return nil, huma.Error500InternalServerError("Failed to delete farm")
|
||||
}
|
||||
|
||||
a.logger.Info("Farm deleted successfully", "farmId", input.FarmID, "ownerId", userID)
|
||||
|
||||
return &DeleteFarmOutput{
|
||||
Body: struct {
|
||||
Message string `json:"message"`
|
||||
}{Message: "Farm deleted successfully"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -77,10 +77,10 @@ type InventoryItemResponse struct {
|
||||
Category InventoryCategory `json:"category"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit HarvestUnit `json:"unit"`
|
||||
DateAdded time.Time `json:"date_added"`
|
||||
DateAdded time.Time `json:"dateAdded"`
|
||||
Status InventoryStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type InventoryStatus struct {
|
||||
@ -100,14 +100,13 @@ type HarvestUnit struct {
|
||||
|
||||
type CreateInventoryItemInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
UserID string `header:"user_id" required:"true" example:"user-uuid"`
|
||||
Body struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
CategoryID int `json:"category_id" required:"true"`
|
||||
CategoryID int `json:"categoryId" required:"true"`
|
||||
Quantity float64 `json:"quantity" required:"true"`
|
||||
UnitID int `json:"unit_id" required:"true"`
|
||||
DateAdded time.Time `json:"date_added" required:"true"`
|
||||
StatusID int `json:"status_id" required:"true"`
|
||||
UnitID int `json:"unitId" required:"true"`
|
||||
DateAdded time.Time `json:"dateAdded" required:"true"`
|
||||
StatusID int `json:"statusId" required:"true"`
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,15 +118,14 @@ type CreateInventoryItemOutput struct {
|
||||
|
||||
type UpdateInventoryItemInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
UserID string `header:"user_id" required:"true" example:"user-uuid"`
|
||||
ID string `path:"id"`
|
||||
Body struct {
|
||||
Name string `json:"name"`
|
||||
CategoryID int `json:"category_id"`
|
||||
CategoryID int `json:"categoryId"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitID int `json:"unit_id"`
|
||||
DateAdded time.Time `json:"date_added"`
|
||||
StatusID int `json:"status_id"`
|
||||
UnitID int `json:"unitId"`
|
||||
DateAdded time.Time `json:"dateAdded"`
|
||||
StatusID int `json:"statusId"`
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,14 +135,13 @@ type UpdateInventoryItemOutput struct {
|
||||
|
||||
type GetInventoryItemsInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
UserID string `header:"user_id" required:"true" example:"user-uuid"`
|
||||
CategoryID int `query:"category_id"`
|
||||
StatusID int `query:"status_id"`
|
||||
StartDate time.Time `query:"start_date" format:"date-time"`
|
||||
EndDate time.Time `query:"end_date" format:"date-time"`
|
||||
CategoryID int `query:"categoryId"`
|
||||
StatusID int `query:"statusId"`
|
||||
StartDate time.Time `query:"startDate" format:"date-time"`
|
||||
EndDate time.Time `query:"endDate" format:"date-time"`
|
||||
SearchQuery string `query:"search"`
|
||||
SortBy string `query:"sort_by" enum:"name,quantity,date_added,created_at"`
|
||||
SortOrder string `query:"sort_order" enum:"asc,desc" default:"desc"`
|
||||
SortBy string `query:"sortBy" enum:"name,quantity,dateAdded,createdAt"`
|
||||
SortOrder string `query:"sortOrder" enum:"asc,desc" default:"desc"`
|
||||
}
|
||||
|
||||
type GetInventoryItemsOutput struct {
|
||||
@ -153,7 +150,7 @@ type GetInventoryItemsOutput struct {
|
||||
|
||||
type GetInventoryItemInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
UserID string `header:"user_id" required:"true" example:"user-uuid"`
|
||||
UserID string `header:"userId" required:"true" example:"user-uuid"`
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
@ -163,7 +160,6 @@ type GetInventoryItemOutput struct {
|
||||
|
||||
type DeleteInventoryItemInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
UserID string `header:"user_id" required:"true" example:"user-uuid"`
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
@ -186,8 +182,9 @@ type GetHarvestUnitsOutput struct {
|
||||
}
|
||||
|
||||
func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInventoryItemInput) (*CreateInventoryItemOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
item := &domain.InventoryItem{
|
||||
UserID: input.UserID,
|
||||
UserID: userID,
|
||||
Name: input.Body.Name,
|
||||
CategoryID: input.Body.CategoryID,
|
||||
Quantity: input.Body.Quantity,
|
||||
@ -200,7 +197,7 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven
|
||||
return nil, huma.Error422UnprocessableEntity(err.Error())
|
||||
}
|
||||
|
||||
err := a.inventoryRepo.CreateOrUpdate(ctx, item)
|
||||
err = a.inventoryRepo.CreateOrUpdate(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -211,8 +208,9 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven
|
||||
}
|
||||
|
||||
func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInventoryItemsInput) (*GetInventoryItemsOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
filter := domain.InventoryFilter{
|
||||
UserID: input.UserID,
|
||||
UserID: userID,
|
||||
CategoryID: input.CategoryID,
|
||||
StatusID: input.StatusID,
|
||||
StartDate: input.StartDate,
|
||||
@ -225,7 +223,7 @@ func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInve
|
||||
Direction: input.SortOrder,
|
||||
}
|
||||
|
||||
items, err := a.inventoryRepo.GetByUserID(ctx, input.UserID, filter, sort)
|
||||
items, err := a.inventoryRepo.GetByUserID(ctx, userID, filter, sort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -286,7 +284,8 @@ func (a *api) getInventoryItemHandler(ctx context.Context, input *GetInventoryIt
|
||||
}
|
||||
|
||||
func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInventoryItemInput) (*UpdateInventoryItemOutput, error) {
|
||||
item, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID)
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
item, err := a.inventoryRepo.GetByID(ctx, input.ID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -319,7 +318,7 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID)
|
||||
updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -347,7 +346,8 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven
|
||||
}
|
||||
|
||||
func (a *api) deleteInventoryItemHandler(ctx context.Context, input *DeleteInventoryItemInput) (*DeleteInventoryItemOutput, error) {
|
||||
err := a.inventoryRepo.Delete(ctx, input.ID, input.UserID)
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
err = a.inventoryRepo.Delete(ctx, input.ID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@ -25,7 +26,7 @@ func (a *api) registerOauthRoutes(_ chi.Router, apiInstance huma.API) {
|
||||
|
||||
type ExchangeTokenInput struct {
|
||||
Body struct {
|
||||
AccessToken string `json:"access_token" example:"Google ID token obtained after login"`
|
||||
AccessToken string `json:"accessToken" required:"true" example:"Google ID token"`
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,51 +52,71 @@ func generateRandomPassword(length int) (string, error) {
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// exchangeHandler assumes the provided access token is a Google ID token.
|
||||
// It verifies the token with Google, and if the user doesn't exist,
|
||||
// it creates a new user with a randomly generated password before issuing your JWT.
|
||||
func (a *api) exchangeHandler(ctx context.Context, input *ExchangeTokenInput) (*ExchangeTokenOutput, error) {
|
||||
if input.Body.AccessToken == "" {
|
||||
return nil, errors.New("access token is required")
|
||||
return nil, huma.Error400BadRequest("accessToken is required") // Match JSON tag
|
||||
}
|
||||
|
||||
googleUserID, email, err := utilities.ExtractGoogleUserID(input.Body.AccessToken)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid Google ID token")
|
||||
a.logger.Warn("Invalid Google ID token received", "error", err)
|
||||
return nil, huma.Error401Unauthorized("Invalid Google ID token", err)
|
||||
}
|
||||
if email == "" {
|
||||
a.logger.Error("Google token verification succeeded but email is missing", "googleUserId", googleUserID)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve email from Google token")
|
||||
}
|
||||
|
||||
user, err := a.userRepo.GetByEmail(ctx, email)
|
||||
if err == domain.ErrNotFound {
|
||||
newPassword, err := generateRandomPassword(12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Info("Creating new user from Google OAuth", "email", email, "googleUserId", googleUserID)
|
||||
|
||||
newPassword, passErr := generateRandomPassword(16) // Increase length
|
||||
if passErr != nil {
|
||||
a.logger.Error("Failed to generate random password for OAuth user", "error", passErr)
|
||||
return nil, huma.Error500InternalServerError("User creation failed (password generation)")
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
hashedPassword, hashErr := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if hashErr != nil {
|
||||
a.logger.Error("Failed to hash generated password for OAuth user", "error", hashErr)
|
||||
return nil, huma.Error500InternalServerError("User creation failed (password hashing)")
|
||||
}
|
||||
|
||||
newUser := &domain.User{
|
||||
Email: email,
|
||||
Password: string(hashedPassword),
|
||||
Password: string(hashedPassword), // Store hashed random password
|
||||
IsActive: true, // Activate user immediately
|
||||
// Username can be initially empty or derived from email if needed
|
||||
}
|
||||
if err := a.userRepo.CreateOrUpdate(ctx, newUser); err != nil {
|
||||
return nil, err
|
||||
if createErr := a.userRepo.CreateOrUpdate(ctx, newUser); createErr != nil {
|
||||
a.logger.Error("Failed to save new OAuth user to database", "email", email, "error", createErr)
|
||||
return nil, huma.Error500InternalServerError("Failed to create user account")
|
||||
}
|
||||
user = *newUser
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
a.logger.Error("Database error looking up user by email during OAuth", "email", email, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to process login")
|
||||
}
|
||||
|
||||
// Ensure the existing user is active
|
||||
if !user.IsActive {
|
||||
a.logger.Warn("OAuth login attempt for inactive user", "email", email, "user_uuid", user.UUID)
|
||||
return nil, huma.Error403Forbidden("Account is inactive")
|
||||
}
|
||||
|
||||
// Generate JWT for the user (either existing or newly created)
|
||||
token, err := utilities.CreateJwtToken(user.UUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
a.logger.Error("Failed to create JWT token after OAuth exchange", "user_uuid", user.UUID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to generate session token")
|
||||
}
|
||||
|
||||
output := &ExchangeTokenOutput{}
|
||||
output.Body.JWT = token
|
||||
output.Body.Email = email
|
||||
_ = googleUserID // Maybe need in the future
|
||||
output.Body.Email = email // Return the email for frontend context
|
||||
_ = googleUserID // Maybe log or store this association if needed later
|
||||
|
||||
a.logger.Info("OAuth exchange successful", "email", email, "user_uuid", user.UUID)
|
||||
return output, nil
|
||||
}
|
||||
|
||||
@ -31,7 +31,12 @@ func (a *api) getAllPlantHandler(ctx context.Context, input *struct{}) (*GetAllP
|
||||
resp := &GetAllPlantsOutput{}
|
||||
plants, err := a.plantRepo.GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
a.logger.Error("Failed to get all plants", "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve plants")
|
||||
}
|
||||
|
||||
if plants == nil {
|
||||
plants = []domain.Plant{}
|
||||
}
|
||||
|
||||
resp.Body.Plants = plants
|
||||
|
||||
@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -28,6 +29,7 @@ type getSelfDataInput struct {
|
||||
Authorization string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
}
|
||||
|
||||
// getSelfDataOutput uses domain.User which now has camelCase tags
|
||||
type getSelfDataOutput struct {
|
||||
Body struct {
|
||||
User domain.User `json:"user"`
|
||||
@ -39,24 +41,33 @@ func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSel
|
||||
|
||||
authHeader := input.Authorization
|
||||
if authHeader == "" {
|
||||
return nil, fmt.Errorf("no authorization header provided")
|
||||
return nil, huma.Error401Unauthorized("No authorization header provided")
|
||||
}
|
||||
|
||||
authToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if authToken == "" {
|
||||
return nil, fmt.Errorf("no token provided")
|
||||
return nil, huma.Error401Unauthorized("No token provided in Authorization header")
|
||||
}
|
||||
|
||||
uuid, err := utilities.ExtractUUIDFromToken(authToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
a.logger.Warn("Failed to extract UUID from token", "error", err)
|
||||
return nil, huma.Error401Unauthorized("Invalid or expired token", err)
|
||||
}
|
||||
|
||||
user, err := a.userRepo.GetByUUID(ctx, uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
a.logger.Warn("User data not found for valid token UUID", "user_uuid", uuid)
|
||||
return nil, huma.Error404NotFound(fmt.Sprintf("User data not found for UUID: %s", uuid))
|
||||
}
|
||||
a.logger.Error("Failed to get user data by UUID", "user_uuid", uuid, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve user data")
|
||||
}
|
||||
|
||||
// Ensure password is not included in the response (already handled by `json:"-"`)
|
||||
// user.Password = "" // Redundant if json tag is "-"
|
||||
|
||||
resp.Body.User = user
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
// backend/internal/cmd/api.go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/forfarm/backend/internal/api"
|
||||
"github.com/forfarm/backend/internal/cmdutil"
|
||||
"github.com/forfarm/backend/internal/config"
|
||||
"github.com/forfarm/backend/internal/event"
|
||||
"github.com/forfarm/backend/internal/repository"
|
||||
"github.com/forfarm/backend/internal/services"
|
||||
"github.com/forfarm/backend/internal/workers"
|
||||
)
|
||||
|
||||
func APICmd(ctx context.Context) *cobra.Command {
|
||||
@ -26,32 +32,92 @@ func APICmd(ctx context.Context) *cobra.Command {
|
||||
|
||||
pool, err := cmdutil.NewDatabasePool(ctx, 16)
|
||||
if err != nil {
|
||||
logger.Error("failed to create database pool", "error", err)
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
logger.Info("connected to database")
|
||||
|
||||
api := api.NewAPI(ctx, logger, pool)
|
||||
server := api.Server(port)
|
||||
// --- Event Bus ---
|
||||
eventBus, err := event.NewRabbitMQEventBus(config.RABBITMQ_URL, logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to event bus", "url", config.RABBITMQ_URL, "error", err)
|
||||
return fmt.Errorf("event bus connection failed: %w", err)
|
||||
}
|
||||
defer eventBus.Close()
|
||||
logger.Info("connected to event bus", "url", config.RABBITMQ_URL)
|
||||
|
||||
logger.Info("starting AnalyticService worker for farm-crop analytics")
|
||||
analyticService := services.NewAnalyticsService()
|
||||
|
||||
analyticsRepo := repository.NewPostgresFarmAnalyticsRepository(pool, logger, analyticService)
|
||||
|
||||
farmRepo := repository.NewPostgresFarm(pool)
|
||||
farmRepo.SetEventPublisher(eventBus)
|
||||
|
||||
inventoryRepo := repository.NewPostgresInventory(pool, eventBus)
|
||||
|
||||
croplandRepo := repository.NewPostgresCropland(pool)
|
||||
croplandRepo.SetEventPublisher(eventBus)
|
||||
|
||||
projection := event.NewFarmAnalyticsProjection(eventBus, analyticsRepo, logger)
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("failed to start server", "err", err)
|
||||
if err := projection.Start(ctx); err != nil {
|
||||
logger.Error("FarmAnalyticsProjection failed to start listening", "error", err)
|
||||
}
|
||||
}()
|
||||
logger.Info("Farm Analytics Projection started")
|
||||
|
||||
logger.Info("started API", "port", port)
|
||||
weatherFetcher := api.GetWeatherFetcher() // Get fetcher instance from API setup
|
||||
weatherInterval, err := time.ParseDuration(config.WEATHER_FETCH_INTERVAL)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid WEATHER_FETCH_INTERVAL, using default 15m", "value", config.WEATHER_FETCH_INTERVAL, "error", err)
|
||||
weatherInterval = 15 * time.Minute
|
||||
}
|
||||
weatherUpdater := workers.NewWeatherUpdater(farmRepo, weatherFetcher, eventBus, logger, weatherInterval)
|
||||
weatherUpdater.Start(ctx) // Pass the main context
|
||||
logger.Info("Weather Updater worker started", "interval", weatherInterval)
|
||||
|
||||
<-ctx.Done()
|
||||
apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, inventoryRepo, croplandRepo, farmRepo) // Pass new repo
|
||||
|
||||
server := apiInstance.Server(port)
|
||||
|
||||
serverErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("starting API server", "port", port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("API server failed", "error", err)
|
||||
serverErrChan <- err // Send error to channel
|
||||
}
|
||||
close(serverErrChan)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serverErrChan:
|
||||
logger.Error("Server error received, initiating shutdown.", "error", err)
|
||||
case <-ctx.Done():
|
||||
logger.Info("Shutdown signal received, initiating graceful shutdown...")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) // 15-second grace period
|
||||
defer cancel()
|
||||
|
||||
weatherUpdater.Stop() // Signal and wait
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("HTTP server graceful shutdown failed", "error", err)
|
||||
} else {
|
||||
logger.Info("HTTP server shutdown complete.")
|
||||
}
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Error("failed to gracefully shutdown server", "err", err)
|
||||
}
|
||||
|
||||
logger.Info("Application shutdown complete.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Add flags if needed (e.g., --port)
|
||||
// cmd.Flags().IntVarP(&port, "port", "p", config.PORT, "Port for the API server")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
54
backend/internal/cmd/rollback.go
Normal file
54
backend/internal/cmd/rollback.go
Normal file
@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/forfarm/backend/migrations"
|
||||
)
|
||||
|
||||
func RollbackCmd(ctx context.Context, dbDriver, dbSource string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rollback [version]",
|
||||
Short: "Rollback database migrations to a specific version",
|
||||
Args: cobra.ExactArgs(1), // Ensure exactly one argument is provided
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
db, err := sql.Open(dbDriver, dbSource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
targetVersion := args[0]
|
||||
targetVersionInt, err := strconv.Atoi(targetVersion)
|
||||
if err != nil {
|
||||
logger.Error("failed to convert version to integer", "version", targetVersion)
|
||||
return err
|
||||
}
|
||||
targetVersionInt64 := int64(targetVersionInt)
|
||||
|
||||
if err := goose.SetDialect(dbDriver); err != nil {
|
||||
return fmt.Errorf("failed to set dialect: %w", err)
|
||||
}
|
||||
|
||||
if err := goose.DownTo(db, migrations.MigrationsDir, targetVersionInt64); err != nil {
|
||||
return fmt.Errorf("failed to rollback to version %s: %w", targetVersion, err)
|
||||
}
|
||||
|
||||
logger.Info("Successfully rolled back to version", "version", targetVersion)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@ -19,6 +19,7 @@ func Execute(ctx context.Context) int {
|
||||
|
||||
rootCmd.AddCommand(APICmd(ctx))
|
||||
rootCmd.AddCommand(MigrateCmd(ctx, "pgx", config.DATABASE_URL))
|
||||
rootCmd.AddCommand(RollbackCmd(ctx, "pgx", config.DATABASE_URL))
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return 1
|
||||
|
||||
@ -7,15 +7,19 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
PORT int
|
||||
POSTGRES_USER string
|
||||
POSTGRES_PASSWORD string
|
||||
POSTGRES_DB string
|
||||
DATABASE_URL string
|
||||
GOOGLE_CLIENT_ID string
|
||||
GOOGLE_CLIENT_SECRET string
|
||||
GOOGLE_REDIRECT_URL string
|
||||
JWT_SECRET_KEY string
|
||||
PORT int
|
||||
POSTGRES_USER string
|
||||
POSTGRES_PASSWORD string
|
||||
POSTGRES_DB string
|
||||
DATABASE_URL string
|
||||
GOOGLE_CLIENT_ID string
|
||||
GOOGLE_CLIENT_SECRET string
|
||||
GOOGLE_REDIRECT_URL string
|
||||
JWT_SECRET_KEY string
|
||||
RABBITMQ_URL string
|
||||
OPENWEATHER_API_KEY string
|
||||
OPENWEATHER_CACHE_TTL string
|
||||
WEATHER_FETCH_INTERVAL string
|
||||
)
|
||||
|
||||
func Load() {
|
||||
@ -28,6 +32,10 @@ func Load() {
|
||||
viper.SetDefault("GOOGLE_CLIENT_SECRET", "google_client_secret")
|
||||
viper.SetDefault("JWT_SECRET_KEY", "jwt_secret_key")
|
||||
viper.SetDefault("GOOGLE_REDIRECT_URL", "http://localhost:8000/auth/login/google")
|
||||
viper.SetDefault("RABBITMQ_URL", "amqp://user:password@localhost:5672/")
|
||||
viper.SetDefault("OPENWEATHER_API_KEY", "openweather_api_key")
|
||||
viper.SetDefault("OPENWEATHER_CACHE_TTL", "15m")
|
||||
viper.SetDefault("WEATHER_FETCH_INTERVAL", "15m")
|
||||
|
||||
viper.SetConfigFile(".env")
|
||||
viper.AddConfigPath("../../.")
|
||||
@ -47,4 +55,8 @@ func Load() {
|
||||
GOOGLE_CLIENT_SECRET = viper.GetString("GOOGLE_CLIENT_SECRET")
|
||||
GOOGLE_REDIRECT_URL = viper.GetString("GOOGLE_REDIRECT_URL")
|
||||
JWT_SECRET_KEY = viper.GetString("JWT_SECRET_KEY")
|
||||
RABBITMQ_URL = viper.GetString("RABBITMQ_URL")
|
||||
OPENWEATHER_API_KEY = viper.GetString("OPENWEATHER_API_KEY")
|
||||
OPENWEATHER_CACHE_TTL = viper.GetString("OPENWEATHER_CACHE_TTL")
|
||||
WEATHER_FETCH_INTERVAL = viper.GetString("WEATHER_FETCH_INTERVAL")
|
||||
}
|
||||
|
||||
67
backend/internal/domain/analytics.go
Normal file
67
backend/internal/domain/analytics.go
Normal file
@ -0,0 +1,67 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FarmAnalytics struct {
|
||||
FarmID string `json:"farmId"`
|
||||
FarmName string `json:"farmName"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
FarmType *string `json:"farmType,omitempty"`
|
||||
TotalSize *string `json:"totalSize,omitempty"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Weather *WeatherData
|
||||
InventoryInfo struct {
|
||||
TotalItems int `json:"totalItems"`
|
||||
LowStockCount int `json:"lowStockCount"`
|
||||
LastUpdated *time.Time `json:"lastUpdated,omitempty"`
|
||||
} `json:"inventoryInfo"`
|
||||
CropInfo struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
GrowingCount int `json:"growingCount"`
|
||||
LastUpdated *time.Time `json:"lastUpdated,omitempty"`
|
||||
} `json:"cropInfo"`
|
||||
OverallStatus *string `json:"overallStatus,omitempty"`
|
||||
AnalyticsLastUpdated time.Time `json:"analyticsLastUpdated"`
|
||||
}
|
||||
|
||||
type CropAnalytics struct {
|
||||
CropID string `json:"cropId"`
|
||||
CropName string `json:"cropName"`
|
||||
FarmID string `json:"farmId"`
|
||||
PlantName string `json:"plantName"`
|
||||
Variety *string `json:"variety,omitempty"`
|
||||
CurrentStatus string `json:"currentStatus"`
|
||||
GrowthStage string `json:"growthStage"`
|
||||
GrowthProgress int `json:"growthProgress"`
|
||||
LandSize float64 `json:"landSize"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Humidity *float64 `json:"humidity,omitempty"`
|
||||
SoilMoisture *float64 `json:"soilMoisture,omitempty"`
|
||||
Sunlight *float64 `json:"sunlight,omitempty"`
|
||||
WindSpeed *float64 `json:"windSpeed,omitempty"`
|
||||
Rainfall *float64 `json:"rainfall,omitempty"` // (maps to RainVolume1h) GrowthProgress int `json:"growthProgress"`
|
||||
NextAction *string `json:"nextAction,omitempty"`
|
||||
NextActionDue *time.Time `json:"nextActionDue,omitempty"`
|
||||
NutrientLevels *struct {
|
||||
Nitrogen *float64 `json:"nitrogen,omitempty"`
|
||||
Phosphorus *float64 `json:"phosphorus,omitempty"`
|
||||
Potassium *float64 `json:"potassium,omitempty"`
|
||||
} `json:"nutrientLevels,omitempty"`
|
||||
PlantHealth *string `json:"plantHealth,omitempty"`
|
||||
}
|
||||
|
||||
type AnalyticsRepository interface {
|
||||
GetFarmAnalytics(ctx context.Context, farmID string) (*FarmAnalytics, error)
|
||||
GetCropAnalytics(ctx context.Context, cropID string) (*CropAnalytics, error)
|
||||
CreateOrUpdateFarmBaseData(ctx context.Context, farm *Farm) error
|
||||
UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *WeatherData) error
|
||||
UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error
|
||||
UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error
|
||||
DeleteFarmAnalytics(ctx context.Context, farmID string) error
|
||||
UpdateFarmOverallStatus(ctx context.Context, farmID string, status string) error
|
||||
}
|
||||
@ -2,22 +2,24 @@ package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
)
|
||||
|
||||
type Cropland struct {
|
||||
UUID string
|
||||
Name string
|
||||
Status string
|
||||
Priority int
|
||||
LandSize float64
|
||||
GrowthStage string
|
||||
PlantID string
|
||||
FarmID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
LandSize float64 `json:"landSize"`
|
||||
GrowthStage string `json:"growthStage"`
|
||||
PlantID string `json:"plantId"`
|
||||
FarmID string `json:"farmId"`
|
||||
GeoFeature json.RawMessage `json:"geoFeature,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (c *Cropland) Validate() error {
|
||||
@ -32,7 +34,8 @@ func (c *Cropland) Validate() error {
|
||||
type CroplandRepository interface {
|
||||
GetByID(context.Context, string) (Cropland, error)
|
||||
GetByFarmID(ctx context.Context, farmID string) ([]Cropland, error)
|
||||
GetAll(ctx context.Context) ([]Cropland, error) // Add this method
|
||||
GetAll(ctx context.Context) ([]Cropland, error)
|
||||
CreateOrUpdate(context.Context, *Cropland) error
|
||||
Delete(context.Context, string) error
|
||||
SetEventPublisher(EventPublisher)
|
||||
}
|
||||
|
||||
28
backend/internal/domain/event.go
Normal file
28
backend/internal/domain/event.go
Normal file
@ -0,0 +1,28 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string
|
||||
Type string
|
||||
Source string
|
||||
Timestamp time.Time
|
||||
Payload interface{}
|
||||
AggregateID string
|
||||
}
|
||||
|
||||
type EventPublisher interface {
|
||||
Publish(ctx context.Context, event Event) error
|
||||
}
|
||||
|
||||
type EventSubscriber interface {
|
||||
Subscribe(ctx context.Context, eventType string, handler func(Event) error) error
|
||||
}
|
||||
|
||||
type EventBus interface {
|
||||
EventPublisher
|
||||
EventSubscriber
|
||||
}
|
||||
@ -8,13 +8,16 @@ import (
|
||||
)
|
||||
|
||||
type Farm struct {
|
||||
UUID string
|
||||
Name string
|
||||
Lat []float64
|
||||
Lon []float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
OwnerID string
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
FarmType string `json:"farmType,omitempty"`
|
||||
TotalSize string `json:"totalSize,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Crops []Cropland `json:"crops,omitempty"`
|
||||
}
|
||||
|
||||
func (f *Farm) Validate() error {
|
||||
@ -27,8 +30,9 @@ func (f *Farm) Validate() error {
|
||||
}
|
||||
|
||||
type FarmRepository interface {
|
||||
GetByID(context.Context, string) (Farm, error)
|
||||
GetByID(context.Context, string) (*Farm, error)
|
||||
GetByOwnerID(context.Context, string) ([]Farm, error)
|
||||
CreateOrUpdate(context.Context, *Farm) error
|
||||
Delete(context.Context, string) error
|
||||
SetEventPublisher(EventPublisher)
|
||||
}
|
||||
|
||||
@ -23,19 +23,19 @@ type HarvestUnit struct {
|
||||
}
|
||||
|
||||
type InventoryItem struct {
|
||||
ID string
|
||||
UserID string
|
||||
Name string
|
||||
CategoryID int
|
||||
Category InventoryCategory
|
||||
Quantity float64
|
||||
UnitID int
|
||||
Unit HarvestUnit
|
||||
DateAdded time.Time
|
||||
StatusID int
|
||||
Status InventoryStatus
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
CategoryID int `json:"categoryId"`
|
||||
Category InventoryCategory `json:"category"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitID int `json:"unitId"`
|
||||
Unit HarvestUnit `json:"unit"`
|
||||
DateAdded time.Time `json:"dateAdded"`
|
||||
StatusID int `json:"statusId"`
|
||||
Status InventoryStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type InventoryFilter struct {
|
||||
|
||||
@ -8,28 +8,28 @@ import (
|
||||
)
|
||||
|
||||
type Plant struct {
|
||||
UUID string
|
||||
Name string
|
||||
Variety *string
|
||||
RowSpacing *float64
|
||||
OptimalTemp *float64
|
||||
PlantingDepth *float64
|
||||
AverageHeight *float64
|
||||
LightProfileID int
|
||||
SoilConditionID int
|
||||
PlantingDetail *string
|
||||
IsPerennial bool
|
||||
DaysToEmerge *int
|
||||
DaysToFlower *int
|
||||
DaysToMaturity *int
|
||||
HarvestWindow *int
|
||||
PHValue *float64
|
||||
EstimateLossRate *float64
|
||||
EstimateRevenuePerHU *float64
|
||||
HarvestUnitID int
|
||||
WaterNeeds *float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Variety *string `json:"variety,omitempty"`
|
||||
RowSpacing *float64 `json:"rowSpacing,omitempty"`
|
||||
OptimalTemp *float64 `json:"optimalTemp,omitempty"`
|
||||
PlantingDepth *float64 `json:"plantingDepth,omitempty"`
|
||||
AverageHeight *float64 `json:"averageHeight,omitempty"`
|
||||
LightProfileID int `json:"lightProfileId"`
|
||||
SoilConditionID int `json:"soilConditionId"`
|
||||
PlantingDetail *string `json:"plantingDetail,omitempty"`
|
||||
IsPerennial bool `json:"isPerennial"`
|
||||
DaysToEmerge *int `json:"daysToEmerge,omitempty"`
|
||||
DaysToFlower *int `json:"daysToFlower,omitempty"`
|
||||
DaysToMaturity *int `json:"daysToMaturity,omitempty"`
|
||||
HarvestWindow *int `json:"harvestWindow,omitempty"`
|
||||
PHValue *float64 `json:"phValue,omitempty"`
|
||||
EstimateLossRate *float64 `json:"estimateLossRate,omitempty"`
|
||||
EstimateRevenuePerHU *float64 `json:"estimateRevenuePerHu,omitempty"`
|
||||
HarvestUnitID int `json:"harvestUnitId"`
|
||||
WaterNeeds *float64 `json:"waterNeeds,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (p *Plant) Validate() error {
|
||||
|
||||
@ -11,14 +11,14 @@ import (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
UUID string
|
||||
Username string
|
||||
Password string
|
||||
Email string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
IsActive bool
|
||||
ID int64 `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"-"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
IsActive bool `json:"isActive"`
|
||||
}
|
||||
|
||||
func (u *User) NormalizedUsername() string {
|
||||
@ -29,16 +29,27 @@ func (u *User) Validate() error {
|
||||
return validation.ValidateStruct(u,
|
||||
validation.Field(&u.UUID, validation.Required),
|
||||
validation.Field(&u.Username, validation.By(func(value interface{}) error {
|
||||
// Username is now optional
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if value == "" {
|
||||
if strVal, ok := value.(string); ok && strVal == "" {
|
||||
return nil
|
||||
}
|
||||
username, ok := value.(*string)
|
||||
if !ok {
|
||||
// If it's a string but not a pointer, handle it
|
||||
if strVal, ok := value.(string); ok {
|
||||
if len(strVal) < 3 || len(strVal) > 20 {
|
||||
return errors.New("username length must be between 3 and 20")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("invalid type for username")
|
||||
}
|
||||
if username == nil || *username == "" {
|
||||
return nil // Optional field is valid if empty or nil
|
||||
}
|
||||
if len(*username) < 3 || len(*username) > 20 {
|
||||
return errors.New("username length must be between 3 and 20")
|
||||
}
|
||||
|
||||
21
backend/internal/domain/weather.go
Normal file
21
backend/internal/domain/weather.go
Normal file
@ -0,0 +1,21 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WeatherData struct {
|
||||
TempCelsius *float64 `json:"tempCelsius,omitempty"`
|
||||
Humidity *float64 `json:"humidity,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
WindSpeed *float64 `json:"windSpeed,omitempty"`
|
||||
RainVolume1h *float64 `json:"rainVolume1h,omitempty"`
|
||||
ObservedAt *time.Time `json:"observedAt,omitempty"`
|
||||
WeatherLastUpdated *time.Time `json:"weatherLastUpdated,omitempty"`
|
||||
}
|
||||
|
||||
type WeatherFetcher interface {
|
||||
GetCurrentWeatherByCoords(ctx context.Context, lat, lon float64) (*WeatherData, error)
|
||||
}
|
||||
64
backend/internal/event/aggregator.go
Normal file
64
backend/internal/event/aggregator.go
Normal file
@ -0,0 +1,64 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
)
|
||||
|
||||
type EventAggregator struct {
|
||||
sourceSubscriber domain.EventSubscriber
|
||||
targetPublisher domain.EventPublisher
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewEventAggregator(
|
||||
sourceSubscriber domain.EventSubscriber,
|
||||
targetPublisher domain.EventPublisher,
|
||||
logger *slog.Logger,
|
||||
) *EventAggregator {
|
||||
return &EventAggregator{
|
||||
sourceSubscriber: sourceSubscriber,
|
||||
targetPublisher: targetPublisher,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *EventAggregator) Start(ctx context.Context) error {
|
||||
// Subscribe to fine-grained events
|
||||
eventTypes := []string{
|
||||
"farm.created", "farm.updated", "farm.deleted",
|
||||
"weather.updated", "inventory.changed", "marketplace.transaction",
|
||||
}
|
||||
|
||||
for _, eventType := range eventTypes {
|
||||
if err := a.sourceSubscriber.Subscribe(ctx, eventType, a.handleEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *EventAggregator) handleEvent(event domain.Event) error {
|
||||
// Logic to aggregate events
|
||||
// For example, combine farm and weather events into a farm status event
|
||||
|
||||
if event.Type == "farm.created" || event.Type == "farm.updated" {
|
||||
// Create a coarse-grained event
|
||||
aggregatedEvent := domain.Event{
|
||||
ID: event.ID,
|
||||
Type: "farm.status_changed",
|
||||
Source: "event-aggregator",
|
||||
Timestamp: time.Now(),
|
||||
Payload: event.Payload,
|
||||
AggregateID: event.AggregateID,
|
||||
}
|
||||
|
||||
return a.targetPublisher.Publish(context.Background(), aggregatedEvent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
150
backend/internal/event/eventbus.go
Normal file
150
backend/internal/event/eventbus.go
Normal file
@ -0,0 +1,150 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type RabbitMQEventBus struct {
|
||||
conn *amqp.Connection
|
||||
channel *amqp.Channel
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewRabbitMQEventBus(url string, logger *slog.Logger) (*RabbitMQEventBus, error) {
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Declare the exchange
|
||||
err = ch.ExchangeDeclare(
|
||||
"events", // name
|
||||
"topic", // type
|
||||
true, // durable
|
||||
false, // auto-deleted
|
||||
false, // internal
|
||||
false, // no-wait
|
||||
nil, // arguments
|
||||
)
|
||||
if err != nil {
|
||||
ch.Close()
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RabbitMQEventBus{
|
||||
conn: conn,
|
||||
channel: ch,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *RabbitMQEventBus) Publish(ctx context.Context, event domain.Event) error {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.channel.PublishWithContext(
|
||||
ctx,
|
||||
"events", // exchange
|
||||
"events."+event.Type, // routing key
|
||||
false, // mandatory
|
||||
false, // immediate
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: data,
|
||||
DeliveryMode: amqp.Persistent,
|
||||
MessageId: event.ID,
|
||||
Timestamp: event.Timestamp,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (r *RabbitMQEventBus) Subscribe(ctx context.Context, eventType string, handler func(domain.Event) error) error {
|
||||
// Declare a queue for this consumer
|
||||
q, err := r.channel.QueueDeclare(
|
||||
"", // name (empty = auto-generated)
|
||||
false, // durable
|
||||
true, // delete when unused
|
||||
true, // exclusive
|
||||
false, // no-wait
|
||||
nil, // arguments
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bind the queue to the exchange
|
||||
err = r.channel.QueueBind(
|
||||
q.Name, // queue name
|
||||
"events."+eventType, // routing key
|
||||
"events", // exchange
|
||||
false, // no-wait
|
||||
nil, // arguments
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start consuming
|
||||
msgs, err := r.channel.Consume(
|
||||
q.Name, // queue
|
||||
"", // consumer
|
||||
false, // auto-ack
|
||||
false, // exclusive
|
||||
false, // no-local
|
||||
false, // no-wait
|
||||
nil, // args
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-msgs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var event domain.Event
|
||||
if err := json.Unmarshal(msg.Body, &event); err != nil {
|
||||
r.logger.Error("Failed to unmarshal event", "error", err)
|
||||
msg.Nack(false, false)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handler(event); err != nil {
|
||||
r.logger.Error("Failed to handle event", "error", err)
|
||||
msg.Nack(false, true) // requeue
|
||||
} else {
|
||||
msg.Ack(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RabbitMQEventBus) Close() error {
|
||||
if err := r.channel.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.conn.Close()
|
||||
}
|
||||
171
backend/internal/event/projection.go
Normal file
171
backend/internal/event/projection.go
Normal file
@ -0,0 +1,171 @@
|
||||
// backend/internal/event/projection.go
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
)
|
||||
|
||||
type FarmAnalyticsProjection struct {
|
||||
eventSubscriber domain.EventSubscriber
|
||||
repository domain.AnalyticsRepository
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewFarmAnalyticsProjection(
|
||||
subscriber domain.EventSubscriber,
|
||||
repository domain.AnalyticsRepository,
|
||||
logger *slog.Logger,
|
||||
) *FarmAnalyticsProjection {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &FarmAnalyticsProjection{
|
||||
eventSubscriber: subscriber,
|
||||
repository: repository,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *FarmAnalyticsProjection) Start(ctx context.Context) error {
|
||||
eventTypes := []string{
|
||||
"farm.created", "farm.updated", "farm.deleted", // Farm lifecycle
|
||||
"weather.updated", // Weather updates
|
||||
"cropland.created", "cropland.updated", "cropland.deleted", // Crop changes trigger count recalc
|
||||
"inventory.item.created", "inventory.item.updated", "inventory.item.deleted", // Inventory changes trigger timestamp update
|
||||
// Add other events that might influence FarmAnalytics, e.g., "pest.detected", "yield.recorded"
|
||||
}
|
||||
|
||||
p.logger.Info("FarmAnalyticsProjection starting, subscribing to events", "types", eventTypes)
|
||||
|
||||
var errs []error
|
||||
for _, eventType := range eventTypes {
|
||||
if err := p.eventSubscriber.Subscribe(ctx, eventType, p.handleEvent); err != nil {
|
||||
p.logger.Error("Failed to subscribe to event type", "type", eventType, "error", err)
|
||||
errs = append(errs, fmt.Errorf("failed to subscribe to %s: %w", eventType, err))
|
||||
// TODO: Decide if we should continue subscribing or fail hard
|
||||
// return errors.Join(errs...) // Fail hard
|
||||
} else {
|
||||
p.logger.Info("Successfully subscribed to event type", "type", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
p.logger.Info("FarmAnalyticsProjection started successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // 10-second timeout
|
||||
defer cancel()
|
||||
|
||||
p.logger.Debug("Handling event in FarmAnalyticsProjection", "type", event.Type, "aggregate_id", event.AggregateID, "event_id", event.ID)
|
||||
|
||||
farmID := event.AggregateID // Assume AggregateID is the Farm UUID for relevant events
|
||||
|
||||
// Special case: inventory events might use UserID as AggregateID.
|
||||
// Need a way to map UserID to FarmID if necessary, or adjust event publishing.
|
||||
// For now, we assume farmID can be derived or is directly in the payload for inventory events.
|
||||
|
||||
if farmID == "" {
|
||||
payloadMap, ok := event.Payload.(map[string]interface{})
|
||||
if ok {
|
||||
if idVal, ok := payloadMap["farm_id"].(string); ok && idVal != "" {
|
||||
farmID = idVal
|
||||
} else if idVal, ok := payloadMap["user_id"].(string); ok && idVal != "" {
|
||||
// !! WARNING: Need mapping from user_id to farm_id here !!
|
||||
// This is a temp - requires adding userRepo or similar lookup
|
||||
p.logger.Warn("Inventory event received without direct farm_id, cannot update stats", "event_id", event.ID, "user_id", idVal)
|
||||
// Skip inventory stats update if farm_id is missing
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if farmID == "" && event.Type != "farm.deleted" { // farm.deleted uses AggregateID which is the farmID being deleted
|
||||
p.logger.Error("Cannot process event, missing farm_id", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID)
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
switch event.Type {
|
||||
case "farm.created", "farm.updated":
|
||||
// Need to get the full Farm domain object from the payload
|
||||
var farmData domain.Farm
|
||||
jsonData, _ := json.Marshal(event.Payload) // Convert payload map back to JSON
|
||||
if err = json.Unmarshal(jsonData, &farmData); err != nil {
|
||||
p.logger.Error("Failed to unmarshal farm data from event payload", "event_id", event.ID, "error", err)
|
||||
// Nack or Ack based on error strategy? Ack for now.
|
||||
return nil
|
||||
}
|
||||
// Ensure UUID is set from AggregateID if missing in payload itself
|
||||
if farmData.UUID == "" {
|
||||
farmData.UUID = event.AggregateID
|
||||
}
|
||||
err = p.repository.CreateOrUpdateFarmBaseData(ctx, &farmData)
|
||||
|
||||
case "farm.deleted":
|
||||
farmID = event.AggregateID // Use AggregateID directly for delete
|
||||
if farmID == "" {
|
||||
p.logger.Error("Cannot process farm.deleted event, missing farm_id in AggregateID", "event_id", event.ID)
|
||||
return nil
|
||||
}
|
||||
err = p.repository.DeleteFarmAnalytics(ctx, farmID)
|
||||
|
||||
case "weather.updated":
|
||||
// Extract weather data from payload
|
||||
var weatherData domain.WeatherData
|
||||
jsonData, _ := json.Marshal(event.Payload)
|
||||
if err = json.Unmarshal(jsonData, &weatherData); err != nil {
|
||||
p.logger.Error("Failed to unmarshal weather data from event payload", "event_id", event.ID, "error", err)
|
||||
return nil // Acknowledge bad data
|
||||
}
|
||||
err = p.repository.UpdateFarmAnalyticsWeather(ctx, farmID, &weatherData)
|
||||
|
||||
case "cropland.created", "cropland.updated", "cropland.deleted":
|
||||
payloadMap, ok := event.Payload.(map[string]interface{})
|
||||
if !ok {
|
||||
p.logger.Error("Failed to cast cropland event payload to map", "event_id", event.ID)
|
||||
return nil
|
||||
}
|
||||
idVal, ok := payloadMap["farm_id"].(string)
|
||||
if !ok || idVal == "" {
|
||||
p.logger.Error("Missing farm_id in cropland event payload", "event_id", event.ID, "event_type", event.Type)
|
||||
return nil
|
||||
}
|
||||
farmID = idVal
|
||||
err = p.repository.UpdateFarmAnalyticsCropStats(ctx, farmID)
|
||||
|
||||
case "inventory.item.created", "inventory.item.updated", "inventory.item.deleted":
|
||||
// farmID needs to be looked up or present in payload
|
||||
// For now, we only touch the timestamp
|
||||
if farmID != "" {
|
||||
err = p.repository.UpdateFarmAnalyticsInventoryStats(ctx, farmID)
|
||||
} else {
|
||||
p.logger.Warn("Skipping inventory stats update due to missing farm_id", "event_id", event.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
default:
|
||||
p.logger.Warn("Received unhandled event type", "type", event.Type, "event_id", event.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
p.logger.Error("Failed to update farm analytics", "event_type", event.Type, "farm_id", farmID, "error", err)
|
||||
// Decide whether to return the error (potentially causing requeue) or nil (ack)
|
||||
return nil
|
||||
}
|
||||
|
||||
p.logger.Debug("Successfully processed event and updated farm analytics", "event_type", event.Type, "farm_id", farmID)
|
||||
return nil
|
||||
}
|
||||
@ -2,7 +2,10 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@ -10,34 +13,31 @@ import (
|
||||
)
|
||||
|
||||
type postgresCroplandRepository struct {
|
||||
conn Connection
|
||||
conn Connection
|
||||
eventPublisher domain.EventPublisher
|
||||
}
|
||||
|
||||
func NewPostgresCropland(conn Connection) domain.CroplandRepository {
|
||||
return &postgresCroplandRepository{conn: conn}
|
||||
}
|
||||
|
||||
func (p *postgresCroplandRepository) SetEventPublisher(publisher domain.EventPublisher) {
|
||||
p.eventPublisher = publisher
|
||||
}
|
||||
|
||||
func (p *postgresCroplandRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Cropland, error) {
|
||||
rows, err := p.conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var croplands []domain.Cropland
|
||||
for rows.Next() {
|
||||
var c domain.Cropland
|
||||
if err := rows.Scan(
|
||||
&c.UUID,
|
||||
&c.Name,
|
||||
&c.Status,
|
||||
&c.Priority,
|
||||
&c.LandSize,
|
||||
&c.GrowthStage,
|
||||
&c.PlantID,
|
||||
&c.FarmID,
|
||||
&c.CreatedAt,
|
||||
&c.UpdatedAt,
|
||||
&c.UUID, &c.Name, &c.Status, &c.Priority, &c.LandSize,
|
||||
&c.GrowthStage, &c.PlantID, &c.FarmID, &c.GeoFeature,
|
||||
&c.CreatedAt, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -46,9 +46,17 @@ func (p *postgresCroplandRepository) fetch(ctx context.Context, query string, ar
|
||||
return croplands, nil
|
||||
}
|
||||
|
||||
func (p *postgresCroplandRepository) GetAll(ctx context.Context) ([]domain.Cropland, error) {
|
||||
query := `
|
||||
SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, geo_feature, created_at, updated_at
|
||||
FROM croplands`
|
||||
|
||||
return p.fetch(ctx, query)
|
||||
}
|
||||
|
||||
func (p *postgresCroplandRepository) GetByID(ctx context.Context, uuid string) (domain.Cropland, error) {
|
||||
query := `
|
||||
SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at
|
||||
SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, geo_feature, created_at, updated_at
|
||||
FROM croplands
|
||||
WHERE uuid = $1`
|
||||
|
||||
@ -64,7 +72,7 @@ func (p *postgresCroplandRepository) GetByID(ctx context.Context, uuid string) (
|
||||
|
||||
func (p *postgresCroplandRepository) GetByFarmID(ctx context.Context, farmID string) ([]domain.Cropland, error) {
|
||||
query := `
|
||||
SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at
|
||||
SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, geo_feature, created_at, updated_at
|
||||
FROM croplands
|
||||
WHERE farm_id = $1`
|
||||
|
||||
@ -72,47 +80,119 @@ func (p *postgresCroplandRepository) GetByFarmID(ctx context.Context, farmID str
|
||||
}
|
||||
|
||||
func (p *postgresCroplandRepository) CreateOrUpdate(ctx context.Context, c *domain.Cropland) error {
|
||||
isNew := false
|
||||
if strings.TrimSpace(c.UUID) == "" {
|
||||
c.UUID = uuid.New().String()
|
||||
c.UUID = uuid.NewString()
|
||||
isNew = true
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO croplands (uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
ON CONFLICT (uuid) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
status = EXCLUDED.status,
|
||||
priority = EXCLUDED.priority,
|
||||
land_size = EXCLUDED.land_size,
|
||||
growth_stage = EXCLUDED.growth_stage,
|
||||
plant_id = EXCLUDED.plant_id,
|
||||
farm_id = EXCLUDED.farm_id,
|
||||
updated_at = NOW()
|
||||
RETURNING uuid, created_at, updated_at`
|
||||
if c.GeoFeature != nil && len(c.GeoFeature) == 0 {
|
||||
c.GeoFeature = nil
|
||||
}
|
||||
|
||||
return p.conn.QueryRow(
|
||||
ctx,
|
||||
query,
|
||||
c.UUID,
|
||||
c.Name,
|
||||
c.Status,
|
||||
c.Priority,
|
||||
c.LandSize,
|
||||
c.GrowthStage,
|
||||
c.PlantID,
|
||||
c.FarmID,
|
||||
).Scan(&c.UUID, &c.CreatedAt, &c.UpdatedAt) // Fixed Scan call
|
||||
query := `
|
||||
INSERT INTO croplands (
|
||||
uuid, name, status, priority, land_size, growth_stage,
|
||||
plant_id, farm_id, geo_feature, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
ON CONFLICT (uuid) DO UPDATE
|
||||
SET name = EXCLUDED.name, status = EXCLUDED.status, priority = EXCLUDED.priority,
|
||||
land_size = EXCLUDED.land_size, growth_stage = EXCLUDED.growth_stage,
|
||||
plant_id = EXCLUDED.plant_id, farm_id = EXCLUDED.farm_id,
|
||||
geo_feature = EXCLUDED.geo_feature, updated_at = NOW()
|
||||
RETURNING uuid, created_at, updated_at`
|
||||
|
||||
err := p.conn.QueryRow(
|
||||
ctx, query,
|
||||
c.UUID, c.Name, c.Status, c.Priority, c.LandSize, c.GrowthStage,
|
||||
c.PlantID, c.FarmID, c.GeoFeature,
|
||||
).Scan(&c.UUID, &c.CreatedAt, &c.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.eventPublisher != nil {
|
||||
eventType := "cropland.updated"
|
||||
if isNew {
|
||||
eventType = "cropland.created"
|
||||
}
|
||||
|
||||
// Avoid sending raw json.RawMessage directly if possible
|
||||
var geoFeatureMap interface{}
|
||||
if c.GeoFeature != nil {
|
||||
_ = json.Unmarshal(c.GeoFeature, &geoFeatureMap)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"crop_id": c.UUID,
|
||||
"name": c.Name,
|
||||
"status": c.Status,
|
||||
"priority": c.Priority,
|
||||
"land_size": c.LandSize,
|
||||
"growth_stage": c.GrowthStage,
|
||||
"plant_id": c.PlantID,
|
||||
"farm_id": c.FarmID,
|
||||
"geo_feature": geoFeatureMap,
|
||||
"created_at": c.CreatedAt,
|
||||
"updated_at": c.UpdatedAt,
|
||||
"event_type": eventType,
|
||||
}
|
||||
|
||||
event := domain.Event{
|
||||
ID: uuid.NewString(),
|
||||
Type: eventType,
|
||||
Source: "cropland-repository",
|
||||
Timestamp: time.Now().UTC(),
|
||||
AggregateID: c.UUID,
|
||||
Payload: payload,
|
||||
}
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil {
|
||||
fmt.Printf("Error publishing %s event: %v\n", eventType, errPub) // Replace with proper logging
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *postgresCroplandRepository) Delete(ctx context.Context, uuid string) error {
|
||||
// Optional: Fetch details before deleting if needed for event payload
|
||||
// cropland, err := p.GetByID(ctx, uuid) // Might fail if already deleted, handle carefully
|
||||
// if err != nil && !errors.Is(err, domain.ErrNotFound){ return err } // Return actual errors
|
||||
|
||||
query := `DELETE FROM croplands WHERE uuid = $1`
|
||||
_, err := p.conn.Exec(ctx, query, uuid)
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *postgresCroplandRepository) GetAll(ctx context.Context) ([]domain.Cropland, error) {
|
||||
query := `
|
||||
SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at
|
||||
FROM croplands`
|
||||
if p.eventPublisher != nil {
|
||||
eventType := "cropland.deleted"
|
||||
payload := map[string]interface{}{
|
||||
"crop_id": uuid,
|
||||
// Include farm_id if easily available or fetched before delete
|
||||
// "farm_id": cropland.FarmID
|
||||
"event_type": eventType,
|
||||
}
|
||||
event := domain.Event{
|
||||
ID: uuid,
|
||||
Type: eventType,
|
||||
Source: "cropland-repository",
|
||||
Timestamp: time.Now().UTC(),
|
||||
AggregateID: uuid,
|
||||
Payload: payload,
|
||||
}
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil {
|
||||
fmt.Printf("Error publishing %s event: %v\n", eventType, errPub)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return p.fetch(ctx, query)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -3,19 +3,25 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type postgresFarmRepository struct {
|
||||
conn Connection
|
||||
conn Connection
|
||||
eventPublisher domain.EventPublisher
|
||||
}
|
||||
|
||||
func NewPostgresFarm(conn Connection) domain.FarmRepository {
|
||||
return &postgresFarmRepository{conn: conn}
|
||||
}
|
||||
|
||||
func (p *postgresFarmRepository) SetEventPublisher(publisher domain.EventPublisher) {
|
||||
p.eventPublisher = publisher
|
||||
}
|
||||
|
||||
func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Farm, error) {
|
||||
rows, err := p.conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@ -26,74 +32,186 @@ func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args .
|
||||
var farms []domain.Farm
|
||||
for rows.Next() {
|
||||
var f domain.Farm
|
||||
// Order: uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id
|
||||
if err := rows.Scan(
|
||||
&f.UUID,
|
||||
&f.Name,
|
||||
&f.Lat,
|
||||
&f.Lon,
|
||||
&f.FarmType,
|
||||
&f.TotalSize,
|
||||
&f.CreatedAt,
|
||||
&f.UpdatedAt,
|
||||
&f.OwnerID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
farms = append(farms, f)
|
||||
}
|
||||
return farms, nil
|
||||
}
|
||||
|
||||
func (p *postgresFarmRepository) GetByID(ctx context.Context, uuid string) (domain.Farm, error) {
|
||||
func (p *postgresFarmRepository) fetchCroplandsByFarmIDs(ctx context.Context, farmIDs []string) (map[string][]domain.Cropland, error) {
|
||||
if len(farmIDs) == 0 {
|
||||
return make(map[string][]domain.Cropland), nil
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types
|
||||
SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at
|
||||
FROM croplands
|
||||
WHERE farm_id = ANY($1)`
|
||||
|
||||
rows, err := p.conn.Query(ctx, query, farmIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
croplandsByFarmID := make(map[string][]domain.Cropland)
|
||||
for rows.Next() {
|
||||
var c domain.Cropland
|
||||
if err := rows.Scan(
|
||||
&c.UUID,
|
||||
&c.Name,
|
||||
&c.Status,
|
||||
&c.Priority,
|
||||
&c.LandSize,
|
||||
&c.GrowthStage,
|
||||
&c.PlantID,
|
||||
&c.FarmID,
|
||||
&c.CreatedAt,
|
||||
&c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
croplandsByFarmID[c.FarmID] = append(croplandsByFarmID[c.FarmID], c)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return croplandsByFarmID, nil
|
||||
}
|
||||
|
||||
func (p *postgresFarmRepository) GetByID(ctx context.Context, farmId string) (*domain.Farm, error) {
|
||||
query := `
|
||||
SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id
|
||||
FROM farms
|
||||
WHERE uuid = $1`
|
||||
|
||||
farms, err := p.fetch(ctx, query, uuid)
|
||||
var f domain.Farm
|
||||
err := p.conn.QueryRow(ctx, query, farmId).Scan(
|
||||
&f.UUID,
|
||||
&f.Name,
|
||||
&f.Lat,
|
||||
&f.Lon,
|
||||
&f.FarmType,
|
||||
&f.TotalSize,
|
||||
&f.CreatedAt,
|
||||
&f.UpdatedAt,
|
||||
&f.OwnerID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Farm{}, err
|
||||
return nil, err
|
||||
}
|
||||
if len(farms) == 0 {
|
||||
return domain.Farm{}, domain.ErrNotFound
|
||||
}
|
||||
return farms[0], nil
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (p *postgresFarmRepository) GetByOwnerID(ctx context.Context, ownerID string) ([]domain.Farm, error) {
|
||||
query := `
|
||||
SELECT uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types
|
||||
FROM farms
|
||||
SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id
|
||||
FROM farms
|
||||
WHERE owner_id = $1`
|
||||
|
||||
return p.fetch(ctx, query, ownerID)
|
||||
farms, err := p.fetch(ctx, query, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(farms) == 0 {
|
||||
return []domain.Farm{}, nil
|
||||
}
|
||||
|
||||
farmIDs := make([]string, 0, len(farms))
|
||||
farmMap := make(map[string]*domain.Farm, len(farms))
|
||||
for i := range farms {
|
||||
farmIDs = append(farmIDs, farms[i].UUID)
|
||||
farmMap[farms[i].UUID] = &farms[i]
|
||||
}
|
||||
|
||||
croplandsByFarmID, err := p.fetchCroplandsByFarmIDs(ctx, farmIDs)
|
||||
if err != nil {
|
||||
println("Warning: Failed to fetch croplands for farms:", err.Error())
|
||||
return farms, nil
|
||||
}
|
||||
|
||||
for farmID, croplands := range croplandsByFarmID {
|
||||
if farm, ok := farmMap[farmID]; ok {
|
||||
farm.Crops = croplands
|
||||
}
|
||||
}
|
||||
|
||||
return farms, nil
|
||||
}
|
||||
|
||||
func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.Farm) error {
|
||||
if strings.TrimSpace(f.UUID) == "" {
|
||||
isNew := strings.TrimSpace(f.UUID) == ""
|
||||
|
||||
if isNew {
|
||||
f.UUID = uuid.New().String()
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO farms (uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6)
|
||||
query := `
|
||||
INSERT INTO farms (uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW(), $7)
|
||||
ON CONFLICT (uuid) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
lat = EXCLUDED.lat,
|
||||
lon = EXCLUDED.lon,
|
||||
farm_type = EXCLUDED.farm_type,
|
||||
total_size = EXCLUDED.total_size,
|
||||
updated_at = NOW(),
|
||||
owner_id = EXCLUDED.owner_id,
|
||||
plant_types = EXCLUDED.plant_types
|
||||
owner_id = EXCLUDED.owner_id
|
||||
RETURNING uuid, created_at, updated_at`
|
||||
err := p.conn.QueryRow(ctx, query, f.UUID, f.Name, f.Lat, f.Lon, f.FarmType, f.TotalSize, f.OwnerID).
|
||||
Scan(&f.UUID, &f.CreatedAt, &f.UpdatedAt)
|
||||
|
||||
return p.conn.QueryRow(
|
||||
ctx,
|
||||
query,
|
||||
f.UUID,
|
||||
f.Name,
|
||||
f.Lat,
|
||||
f.Lon,
|
||||
f.OwnerID,
|
||||
).Scan(&f.UUID, &f.CreatedAt, &f.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.eventPublisher != nil {
|
||||
eventType := "farm.updated"
|
||||
if isNew {
|
||||
eventType = "farm.created"
|
||||
}
|
||||
|
||||
event := domain.Event{
|
||||
ID: uuid.New().String(),
|
||||
Type: eventType,
|
||||
Source: "farm-repository",
|
||||
Timestamp: time.Now(),
|
||||
AggregateID: f.UUID,
|
||||
Payload: map[string]interface{}{
|
||||
"farm_id": f.UUID,
|
||||
"name": f.Name,
|
||||
"location": map[string]float64{"lat": f.Lat, "lon": f.Lon},
|
||||
"farm_type": f.FarmType,
|
||||
"total_size": f.TotalSize,
|
||||
"owner_id": f.OwnerID,
|
||||
"created_at": f.CreatedAt,
|
||||
"updated_at": f.UpdatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if err := p.eventPublisher.Publish(bgCtx, event); err != nil {
|
||||
println("Failed to publish event", err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *postgresFarmRepository) Delete(ctx context.Context, uuid string) error {
|
||||
|
||||
452
backend/internal/repository/postgres_farm_analytics.go
Normal file
452
backend/internal/repository/postgres_farm_analytics.go
Normal file
@ -0,0 +1,452 @@
|
||||
// backend/internal/repository/postgres_farm_analytics.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/forfarm/backend/internal/services"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type postgresFarmAnalyticsRepository struct {
|
||||
conn Connection
|
||||
logger *slog.Logger
|
||||
analyticsService *services.AnalyticsService
|
||||
}
|
||||
|
||||
func NewPostgresFarmAnalyticsRepository(conn Connection, logger *slog.Logger, analyticsService *services.AnalyticsService) domain.AnalyticsRepository {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if analyticsService == nil {
|
||||
analyticsService = services.NewAnalyticsService()
|
||||
}
|
||||
return &postgresFarmAnalyticsRepository{
|
||||
conn: conn,
|
||||
logger: logger,
|
||||
analyticsService: analyticsService,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *postgresFarmAnalyticsRepository) GetFarmAnalytics(ctx context.Context, farmID string) (*domain.FarmAnalytics, error) {
|
||||
query := `
|
||||
SELECT
|
||||
farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude,
|
||||
weather_temp_celsius, weather_humidity, weather_description, weather_icon,
|
||||
weather_wind_speed, weather_rain_1h, weather_observed_at, weather_last_updated,
|
||||
inventory_total_items, inventory_low_stock_count, inventory_last_updated,
|
||||
crop_total_count, crop_growing_count, crop_last_updated,
|
||||
overall_status, analytics_last_updated
|
||||
FROM public.farm_analytics
|
||||
WHERE farm_id = $1`
|
||||
|
||||
var analytics domain.FarmAnalytics
|
||||
var farmType sql.NullString
|
||||
var totalSize sql.NullString
|
||||
var weatherJSON, inventoryJSON, cropJSON []byte // Use []byte for JSONB
|
||||
var overallStatus sql.NullString
|
||||
|
||||
err := r.conn.QueryRow(ctx, query, farmID).Scan(
|
||||
&analytics.FarmID,
|
||||
&analytics.FarmName,
|
||||
&analytics.OwnerID,
|
||||
&farmType,
|
||||
&totalSize,
|
||||
&analytics.Latitude, // Scan directly into the struct fields
|
||||
&analytics.Longitude,
|
||||
&weatherJSON,
|
||||
&inventoryJSON,
|
||||
&cropJSON,
|
||||
&overallStatus,
|
||||
&analytics.AnalyticsLastUpdated,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
|
||||
r.logger.Warn("Farm analytics data not found", "farm_id", farmID)
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
r.logger.Error("Failed to query farm analytics", "farm_id", farmID, "error", err)
|
||||
return nil, fmt.Errorf("database query failed for farm analytics: %w", err)
|
||||
}
|
||||
|
||||
// Handle nullable fields
|
||||
if farmType.Valid {
|
||||
analytics.FarmType = &farmType.String
|
||||
}
|
||||
if totalSize.Valid {
|
||||
analytics.TotalSize = &totalSize.String
|
||||
}
|
||||
if overallStatus.Valid {
|
||||
analytics.OverallStatus = &overallStatus.String
|
||||
}
|
||||
|
||||
// Unmarshal JSONB data
|
||||
if weatherJSON != nil {
|
||||
if err := json.Unmarshal(weatherJSON, &analytics.Weather); err != nil {
|
||||
r.logger.Warn("Failed to unmarshal weather data from farm analytics", "farm_id", farmID, "error", err)
|
||||
// Continue, but log the issue
|
||||
}
|
||||
}
|
||||
if inventoryJSON != nil {
|
||||
if err := json.Unmarshal(inventoryJSON, &analytics.InventoryInfo); err != nil {
|
||||
r.logger.Warn("Failed to unmarshal inventory data from farm analytics", "farm_id", farmID, "error", err)
|
||||
}
|
||||
}
|
||||
if cropJSON != nil {
|
||||
if err := json.Unmarshal(cropJSON, &analytics.CropInfo); err != nil {
|
||||
r.logger.Warn("Failed to unmarshal crop data from farm analytics", "farm_id", farmID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Debug("Successfully retrieved farm analytics", "farm_id", farmID)
|
||||
return &analytics, nil
|
||||
}
|
||||
|
||||
// --- Calculation Helper ---
|
||||
|
||||
// calculateGrowthProgress calculates the percentage completion based on planting date and maturity days.
|
||||
func calculateGrowthProgress(plantedAt time.Time, daysToMaturity *int) int {
|
||||
if daysToMaturity == nil || *daysToMaturity <= 0 {
|
||||
return 0 // Cannot calculate if maturity days are unknown or zero
|
||||
}
|
||||
if plantedAt.IsZero() {
|
||||
return 0 // Cannot calculate if planting date is unknown
|
||||
}
|
||||
|
||||
today := time.Now()
|
||||
daysElapsed := today.Sub(plantedAt).Hours() / 24
|
||||
progress := (daysElapsed / float64(*daysToMaturity)) * 100
|
||||
|
||||
// Clamp progress between 0 and 100
|
||||
if progress < 0 {
|
||||
return 0
|
||||
}
|
||||
if progress > 100 {
|
||||
return 100
|
||||
}
|
||||
return int(progress)
|
||||
}
|
||||
|
||||
// --- GetCropAnalytics ---
|
||||
|
||||
func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context, cropID string) (*domain.CropAnalytics, error) {
|
||||
// Fetch base data from croplands and plants
|
||||
query := `
|
||||
SELECT
|
||||
c.uuid, c.name, c.farm_id, c.status, c.growth_stage, c.land_size, c.updated_at,
|
||||
p.name, p.variety, p.days_to_maturity,
|
||||
c.created_at -- Planted date proxy
|
||||
FROM
|
||||
croplands c
|
||||
JOIN
|
||||
plants p ON c.plant_id = p.uuid
|
||||
WHERE
|
||||
c.uuid = $1
|
||||
`
|
||||
|
||||
var analytics domain.CropAnalytics // Initialize the struct to be populated
|
||||
var plantName string
|
||||
var variety sql.NullString
|
||||
var daysToMaturity sql.NullInt32
|
||||
var plantedAt time.Time
|
||||
var croplandLastUpdated time.Time // Capture cropland specific update time
|
||||
|
||||
err := r.conn.QueryRow(ctx, query, cropID).Scan(
|
||||
&analytics.CropID,
|
||||
&analytics.CropName,
|
||||
&analytics.FarmID,
|
||||
&analytics.CurrentStatus,
|
||||
&analytics.GrowthStage,
|
||||
&analytics.LandSize,
|
||||
&croplandLastUpdated, // Use this for action suggestion timing
|
||||
&plantName,
|
||||
&variety,
|
||||
&daysToMaturity,
|
||||
&plantedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
// ... (error handling as before) ...
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
|
||||
r.logger.Warn("Crop analytics base data query returned no rows", "crop_id", cropID)
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
r.logger.Error("Failed to query crop base data", "crop_id", cropID, "error", err)
|
||||
return nil, fmt.Errorf("database query failed for crop base data: %w", err)
|
||||
}
|
||||
|
||||
// --- Populate direct fields ---
|
||||
analytics.PlantName = plantName
|
||||
if variety.Valid {
|
||||
analytics.Variety = &variety.String
|
||||
}
|
||||
analytics.LastUpdated = time.Now().UTC() // Set analytics generation time
|
||||
|
||||
// --- Calculate/Fetch derived fields using the service ---
|
||||
|
||||
// Growth Progress
|
||||
var maturityDaysPtr *int
|
||||
if daysToMaturity.Valid {
|
||||
maturityInt := int(daysToMaturity.Int32)
|
||||
maturityDaysPtr = &maturityInt
|
||||
}
|
||||
analytics.GrowthProgress = calculateGrowthProgress(plantedAt, maturityDaysPtr)
|
||||
|
||||
// Environmental Data (includes placeholders)
|
||||
farmAnalytics, farmErr := r.GetFarmAnalytics(ctx, analytics.FarmID)
|
||||
if farmErr != nil && !errors.Is(farmErr, domain.ErrNotFound) {
|
||||
r.logger.Warn("Could not fetch associated farm analytics for crop context", "farm_id", analytics.FarmID, "crop_id", cropID, "error", farmErr)
|
||||
// Proceed without farm-level weather data if farm analytics fetch fails
|
||||
}
|
||||
analytics.Temperature, analytics.Humidity, analytics.WindSpeed, analytics.Rainfall, analytics.Sunlight, analytics.SoilMoisture = r.analyticsService.GetEnvironmentalData(farmAnalytics)
|
||||
|
||||
// Plant Health (Dummy)
|
||||
health := r.analyticsService.CalculatePlantHealth(analytics.CurrentStatus, analytics.GrowthStage)
|
||||
analytics.PlantHealth = &health
|
||||
|
||||
// Next Action (Dummy)
|
||||
analytics.NextAction, analytics.NextActionDue = r.analyticsService.SuggestNextAction(analytics.GrowthStage, croplandLastUpdated) // Use cropland update time
|
||||
|
||||
// Nutrient Levels (Dummy)
|
||||
analytics.NutrientLevels = r.analyticsService.GetNutrientLevels(analytics.CropID)
|
||||
|
||||
// --- End Service Usage ---
|
||||
|
||||
r.logger.Debug("Successfully constructed crop analytics", "crop_id", cropID)
|
||||
return &analytics, nil
|
||||
}
|
||||
|
||||
// --- Implement other AnalyticsRepository methods ---
|
||||
|
||||
func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error {
|
||||
query := `
|
||||
INSERT INTO farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, lat, lon, last_updated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (farm_id) DO UPDATE
|
||||
SET farm_name = EXCLUDED.farm_name,
|
||||
owner_id = EXCLUDED.owner_id,
|
||||
farm_type = EXCLUDED.farm_type,
|
||||
total_size = EXCLUDED.total_size,
|
||||
lat = EXCLUDED.lat,
|
||||
lon = EXCLUDED.lon,
|
||||
last_updated = EXCLUDED.last_updated;`
|
||||
|
||||
_, err := r.conn.Exec(ctx, query,
|
||||
farm.UUID,
|
||||
farm.Name,
|
||||
farm.OwnerID,
|
||||
farm.FarmType, // Handle potential empty string vs null if needed
|
||||
farm.TotalSize, // Handle potential empty string vs null if needed
|
||||
farm.Lat,
|
||||
farm.Lon,
|
||||
time.Now().UTC(), // Update timestamp on change
|
||||
)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to create/update farm base analytics data", "farm_id", farm.UUID, "error", err)
|
||||
return fmt.Errorf("failed to upsert farm base data: %w", err)
|
||||
}
|
||||
r.logger.Debug("Upserted farm base analytics data", "farm_id", farm.UUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *domain.WeatherData) error {
|
||||
if weatherData == nil {
|
||||
return fmt.Errorf("weather data cannot be nil")
|
||||
}
|
||||
|
||||
weatherJSON, err := json.Marshal(weatherData)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to marshal weather data for analytics update", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("failed to marshal weather data: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE farm_analytics
|
||||
SET weather_data = $1,
|
||||
last_updated = $2
|
||||
WHERE farm_id = $3;`
|
||||
|
||||
cmdTag, err := r.conn.Exec(ctx, query, weatherJSON, time.Now().UTC(), farmID)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to update farm analytics weather data", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("database update failed for weather data: %w", err)
|
||||
}
|
||||
if cmdTag.RowsAffected() == 0 {
|
||||
r.logger.Warn("No farm analytics record found to update weather data", "farm_id", farmID)
|
||||
// Optionally, create the base record here if it should always exist
|
||||
return domain.ErrNotFound // Or handle as appropriate
|
||||
}
|
||||
|
||||
r.logger.Debug("Updated farm analytics weather data", "farm_id", farmID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateFarmAnalyticsCropStats needs to query the croplands table for the farm
|
||||
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error {
|
||||
var totalCount, growingCount int
|
||||
|
||||
// Query to count total and growing crops for the farm
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status = 'growing') -- Case-insensitive comparison if needed: LOWER(status) = 'growing'
|
||||
FROM croplands
|
||||
WHERE farm_id = $1;`
|
||||
|
||||
err := r.conn.QueryRow(ctx, query, farmID).Scan(&totalCount, &growingCount)
|
||||
if err != nil {
|
||||
// Log error but don't fail the projection if stats can't be calculated temporarily
|
||||
r.logger.Error("Failed to calculate crop stats for analytics", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("failed to calculate crop stats: %w", err)
|
||||
}
|
||||
|
||||
// Construct the JSONB object for crop_data
|
||||
cropInfo := map[string]interface{}{
|
||||
"totalCount": totalCount,
|
||||
"growingCount": growingCount,
|
||||
"lastUpdated": time.Now().UTC(), // Timestamp of this calculation
|
||||
}
|
||||
cropJSON, err := json.Marshal(cropInfo)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to marshal crop stats data", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("failed to marshal crop stats: %w", err)
|
||||
}
|
||||
|
||||
// Update the farm_analytics table
|
||||
updateQuery := `
|
||||
UPDATE farm_analytics
|
||||
SET crop_data = $1,
|
||||
last_updated = $2 -- Also update the main last_updated timestamp
|
||||
WHERE farm_id = $3;`
|
||||
|
||||
cmdTag, err := r.conn.Exec(ctx, updateQuery, cropJSON, time.Now().UTC(), farmID)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to update farm analytics crop stats", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("database update failed for crop stats: %w", err)
|
||||
}
|
||||
if cmdTag.RowsAffected() == 0 {
|
||||
r.logger.Warn("No farm analytics record found to update crop stats", "farm_id", farmID)
|
||||
// Optionally, create the base record here
|
||||
} else {
|
||||
r.logger.Debug("Updated farm analytics crop stats", "farm_id", farmID, "total", totalCount, "growing", growingCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateFarmAnalyticsInventoryStats needs to query inventory_items
|
||||
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error {
|
||||
var totalItems, lowStockCount int
|
||||
var lastUpdated sql.NullTime
|
||||
|
||||
// Query to get inventory stats for the user owning the farm
|
||||
// NOTE: This assumes inventory is linked by user_id, and we need the user_id for the farm owner.
|
||||
// Step 1: Get Owner ID from farm_analytics table
|
||||
var ownerID string
|
||||
ownerQuery := `SELECT owner_id FROM farm_analytics WHERE farm_id = $1`
|
||||
err := r.conn.QueryRow(ctx, ownerQuery, farmID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
|
||||
r.logger.Warn("Cannot update inventory stats, farm analytics record not found", "farm_id", farmID)
|
||||
return nil // Or return ErrNotFound if critical
|
||||
}
|
||||
r.logger.Error("Failed to get owner ID for inventory stats update", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("failed to get owner ID: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Query inventory based on owner ID
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status_id = (SELECT id FROM inventory_status WHERE name = 'Low Stock')), -- Assumes 'Low Stock' status name
|
||||
MAX(updated_at) -- Get the latest update timestamp from inventory items
|
||||
FROM inventory_items
|
||||
WHERE user_id = $1;`
|
||||
|
||||
err = r.conn.QueryRow(ctx, query, ownerID).Scan(&totalItems, &lowStockCount, &lastUpdated)
|
||||
if err != nil {
|
||||
// Log error but don't fail the projection if stats can't be calculated temporarily
|
||||
r.logger.Error("Failed to calculate inventory stats for analytics", "farm_id", farmID, "owner_id", ownerID, "error", err)
|
||||
return fmt.Errorf("failed to calculate inventory stats: %w", err)
|
||||
}
|
||||
|
||||
// Construct the JSONB object for inventory_data
|
||||
inventoryInfo := map[string]interface{}{
|
||||
"totalItems": totalItems,
|
||||
"lowStockCount": lowStockCount,
|
||||
"lastUpdated": nil, // Initialize as nil
|
||||
}
|
||||
// Only set lastUpdated if the MAX(updated_at) query returned a valid time
|
||||
if lastUpdated.Valid {
|
||||
inventoryInfo["lastUpdated"] = lastUpdated.Time.UTC()
|
||||
}
|
||||
|
||||
inventoryJSON, err := json.Marshal(inventoryInfo)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to marshal inventory stats data", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("failed to marshal inventory stats: %w", err)
|
||||
}
|
||||
|
||||
// Update the farm_analytics table
|
||||
updateQuery := `
|
||||
UPDATE farm_analytics
|
||||
SET inventory_data = $1,
|
||||
last_updated = $2 -- Also update the main last_updated timestamp
|
||||
WHERE farm_id = $3;`
|
||||
|
||||
cmdTag, err := r.conn.Exec(ctx, updateQuery, inventoryJSON, time.Now().UTC(), farmID)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to update farm analytics inventory stats", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("database update failed for inventory stats: %w", err)
|
||||
}
|
||||
if cmdTag.RowsAffected() == 0 {
|
||||
r.logger.Warn("No farm analytics record found to update inventory stats", "farm_id", farmID)
|
||||
} else {
|
||||
r.logger.Debug("Updated farm analytics inventory stats", "farm_id", farmID, "total", totalItems, "lowStock", lowStockCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *postgresFarmAnalyticsRepository) DeleteFarmAnalytics(ctx context.Context, farmID string) error {
|
||||
query := `DELETE FROM farm_analytics WHERE farm_id = $1;`
|
||||
cmdTag, err := r.conn.Exec(ctx, query, farmID)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to delete farm analytics data", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("database delete failed for farm analytics: %w", err)
|
||||
}
|
||||
if cmdTag.RowsAffected() == 0 {
|
||||
r.logger.Warn("No farm analytics record found to delete", "farm_id", farmID)
|
||||
// Return ErrNotFound if it's important to know it wasn't there
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
r.logger.Info("Deleted farm analytics data", "farm_id", farmID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *postgresFarmAnalyticsRepository) UpdateFarmOverallStatus(ctx context.Context, farmID string, status string) error {
|
||||
query := `
|
||||
UPDATE farm_analytics
|
||||
SET overall_status = $1,
|
||||
last_updated = $2
|
||||
WHERE farm_id = $3;`
|
||||
|
||||
cmdTag, err := r.conn.Exec(ctx, query, status, time.Now().UTC(), farmID)
|
||||
if err != nil {
|
||||
r.logger.Error("Failed to update farm overall status", "farm_id", farmID, "status", status, "error", err)
|
||||
return fmt.Errorf("database update failed for overall status: %w", err)
|
||||
}
|
||||
if cmdTag.RowsAffected() == 0 {
|
||||
r.logger.Warn("No farm analytics record found to update overall status", "farm_id", farmID)
|
||||
// Optionally, create the base record here if needed
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
r.logger.Debug("Updated farm overall status", "farm_id", farmID, "status", status)
|
||||
return nil
|
||||
}
|
||||
@ -7,14 +7,16 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type postgresInventoryRepository struct {
|
||||
conn Connection
|
||||
conn Connection
|
||||
eventPublisher domain.EventPublisher
|
||||
}
|
||||
|
||||
func NewPostgresInventory(conn Connection) domain.InventoryRepository {
|
||||
return &postgresInventoryRepository{conn: conn}
|
||||
func NewPostgresInventory(conn Connection, publisher domain.EventPublisher) domain.InventoryRepository {
|
||||
return &postgresInventoryRepository{conn: conn, eventPublisher: publisher}
|
||||
}
|
||||
|
||||
func (p *postgresInventoryRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.InventoryItem, error) {
|
||||
@ -219,60 +221,124 @@ func (p *postgresInventoryRepository) GetAll(ctx context.Context) ([]domain.Inve
|
||||
func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *domain.InventoryItem) error {
|
||||
now := time.Now()
|
||||
item.UpdatedAt = now
|
||||
isNew := false
|
||||
|
||||
if item.ID == "" {
|
||||
isNew = true
|
||||
item.CreatedAt = now
|
||||
query := `
|
||||
INSERT INTO inventory_items
|
||||
(id, user_id, name, category_id, quantity, unit_id, date_added, status_id, created_at, updated_at)
|
||||
query := `
|
||||
INSERT INTO inventory_items
|
||||
(id, user_id, name, category_id, quantity, unit_id, date_added, status_id, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id`
|
||||
return p.conn.QueryRow(
|
||||
err := p.conn.QueryRow(
|
||||
ctx,
|
||||
query,
|
||||
item.UserID,
|
||||
item.Name,
|
||||
item.CategoryID,
|
||||
item.Quantity,
|
||||
item.UnitID,
|
||||
item.DateAdded,
|
||||
item.StatusID,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
item.UserID, item.Name, item.CategoryID, item.Quantity,
|
||||
item.UnitID, item.DateAdded, item.StatusID, item.CreatedAt, item.UpdatedAt,
|
||||
).Scan(&item.ID)
|
||||
if err != nil {
|
||||
// Log error
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
query := `
|
||||
UPDATE inventory_items
|
||||
SET name = $1, category_id = $2, quantity = $3, unit_id = $4,
|
||||
date_added = $5, status_id = $6, updated_at = $7
|
||||
WHERE id = $8 AND user_id = $9
|
||||
RETURNING id` // Ensure RETURNING id exists or handle differently
|
||||
err := p.conn.QueryRow(
|
||||
ctx,
|
||||
query,
|
||||
item.Name, item.CategoryID, item.Quantity, item.UnitID,
|
||||
item.DateAdded, item.StatusID, item.UpdatedAt, item.ID, item.UserID,
|
||||
).Scan(&item.ID) // Scan to confirm update happened or handle potential ErrNoRows
|
||||
if err != nil {
|
||||
// Log error
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE inventory_items
|
||||
SET name = $1,
|
||||
category_id = $2,
|
||||
quantity = $3,
|
||||
unit_id = $4,
|
||||
date_added = $5,
|
||||
status_id = $6,
|
||||
updated_at = $7
|
||||
WHERE id = $8 AND user_id = $9
|
||||
RETURNING id`
|
||||
// --- Publish Event ---
|
||||
if p.eventPublisher != nil {
|
||||
eventType := "inventory.item.updated"
|
||||
if isNew {
|
||||
eventType = "inventory.item.created"
|
||||
}
|
||||
|
||||
return p.conn.QueryRow(
|
||||
ctx,
|
||||
query,
|
||||
item.Name,
|
||||
item.CategoryID,
|
||||
item.Quantity,
|
||||
item.UnitID,
|
||||
item.DateAdded,
|
||||
item.StatusID,
|
||||
item.UpdatedAt,
|
||||
item.ID,
|
||||
item.UserID,
|
||||
).Scan(&item.ID)
|
||||
payload := map[string]interface{}{
|
||||
"item_id": item.ID,
|
||||
"user_id": item.UserID, // Include user ID for potential farm lookup in projection
|
||||
"name": item.Name,
|
||||
"category_id": item.CategoryID,
|
||||
"quantity": item.Quantity,
|
||||
"unit_id": item.UnitID,
|
||||
"status_id": item.StatusID,
|
||||
"date_added": item.DateAdded,
|
||||
"updated_at": item.UpdatedAt,
|
||||
// NO farm_id easily available here without extra lookup
|
||||
}
|
||||
|
||||
event := domain.Event{
|
||||
ID: uuid.NewString(),
|
||||
Type: eventType,
|
||||
Source: "inventory-repository",
|
||||
Timestamp: time.Now().UTC(),
|
||||
AggregateID: item.UserID, // Use UserID as AggregateID for inventory? Or item.ID? Let's use item.ID.
|
||||
Payload: payload,
|
||||
}
|
||||
// Use AggregateID = item.ID for consistency if item is the aggregate root
|
||||
event.AggregateID = item.ID
|
||||
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil {
|
||||
fmt.Printf("Error publishing %s event: %v\n", eventType, errPub) // Use proper logging
|
||||
}
|
||||
}()
|
||||
}
|
||||
// --- End Publish Event ---
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *postgresInventoryRepository) Delete(ctx context.Context, id, userID string) error {
|
||||
query := `DELETE FROM inventory_items WHERE id = $1 AND user_id = $2`
|
||||
_, err := p.conn.Exec(ctx, query, id, userID)
|
||||
return err
|
||||
cmdTag, err := p.conn.Exec(ctx, query, id, userID)
|
||||
if err != nil {
|
||||
// Log error
|
||||
return err
|
||||
}
|
||||
if cmdTag.RowsAffected() == 0 {
|
||||
return domain.ErrNotFound // Or a permission error if user doesn't match
|
||||
}
|
||||
|
||||
// --- Publish Event ---
|
||||
if p.eventPublisher != nil {
|
||||
eventType := "inventory.item.deleted"
|
||||
payload := map[string]interface{}{
|
||||
"item_id": id,
|
||||
"user_id": userID, // Include user ID
|
||||
}
|
||||
event := domain.Event{
|
||||
ID: uuid.NewString(),
|
||||
Type: eventType,
|
||||
Source: "inventory-repository",
|
||||
Timestamp: time.Now().UTC(),
|
||||
AggregateID: id, // Use item ID as aggregate ID
|
||||
Payload: payload,
|
||||
}
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil {
|
||||
fmt.Printf("Error publishing %s event: %v\n", eventType, errPub) // Use proper logging
|
||||
}
|
||||
}()
|
||||
}
|
||||
// --- End Publish Event ---
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *postgresInventoryRepository) GetStatuses(ctx context.Context) ([]domain.InventoryStatus, error) {
|
||||
|
||||
148
backend/internal/services/analytics_service.go
Normal file
148
backend/internal/services/analytics_service.go
Normal file
@ -0,0 +1,148 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
)
|
||||
|
||||
// AnalyticsService provides methods for calculating or deriving analytics data.
|
||||
// For now, it contains dummy implementations.
|
||||
type AnalyticsService struct {
|
||||
// Add dependencies like repositories if needed for real logic later
|
||||
}
|
||||
|
||||
// NewAnalyticsService creates a new AnalyticsService.
|
||||
func NewAnalyticsService() *AnalyticsService {
|
||||
return &AnalyticsService{}
|
||||
}
|
||||
|
||||
// CalculatePlantHealth provides a dummy health status.
|
||||
// TODO: Implement real health calculation based on status, weather, events, etc.
|
||||
func (s *AnalyticsService) CalculatePlantHealth(status string, growthStage string) string {
|
||||
// Simple dummy logic
|
||||
switch status {
|
||||
case "Problem", "Diseased", "Infested":
|
||||
return "warning"
|
||||
case "Fallow", "Harvested":
|
||||
return "n/a" // Or maybe 'good' if fallow is considered healthy state
|
||||
default:
|
||||
// Slightly randomize for demo purposes
|
||||
if rand.Intn(10) < 2 { // 20% chance of warning even if status is 'growing'
|
||||
return "warning"
|
||||
}
|
||||
return "good"
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestNextAction provides a dummy next action based on growth stage.
|
||||
// TODO: Implement real suggestion logic based on stage, weather, history, plant type etc.
|
||||
func (s *AnalyticsService) SuggestNextAction(growthStage string, lastUpdated time.Time) (action *string, dueDate *time.Time) {
|
||||
// Default action
|
||||
nextActionStr := "Monitor crop health"
|
||||
nextDueDate := time.Now().Add(24 * time.Hour) // Check tomorrow
|
||||
|
||||
switch growthStage {
|
||||
case "Planned", "Planting":
|
||||
nextActionStr = "Prepare soil and planting"
|
||||
nextDueDate = time.Now().Add(12 * time.Hour)
|
||||
case "Germination", "Seedling":
|
||||
nextActionStr = "Check for germination success and early pests"
|
||||
nextDueDate = time.Now().Add(48 * time.Hour)
|
||||
case "Vegetative":
|
||||
nextActionStr = "Monitor growth and apply nutrients if needed"
|
||||
nextDueDate = time.Now().Add(72 * time.Hour)
|
||||
case "Flowering", "Budding":
|
||||
nextActionStr = "Check pollination and manage pests/diseases"
|
||||
nextDueDate = time.Now().Add(48 * time.Hour)
|
||||
case "Fruiting", "Ripening":
|
||||
nextActionStr = "Monitor fruit development and prepare for harvest"
|
||||
nextDueDate = time.Now().Add(7 * 24 * time.Hour) // Check in a week
|
||||
case "Harvesting":
|
||||
nextActionStr = "Proceed with harvest"
|
||||
nextDueDate = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
// Only return if the suggestion is "newer" than the last update to avoid constant same suggestion
|
||||
// This is basic logic, real implementation would be more complex
|
||||
if nextDueDate.After(lastUpdated.Add(1 * time.Hour)) { // Only suggest if due date is >1hr after last update
|
||||
return &nextActionStr, &nextDueDate
|
||||
}
|
||||
|
||||
return nil, nil // No immediate action needed or suggestion is old
|
||||
}
|
||||
|
||||
// GetNutrientLevels provides dummy nutrient levels.
|
||||
// TODO: Implement real nutrient level fetching (e.g., from soil sensors, lab results events).
|
||||
func (s *AnalyticsService) GetNutrientLevels(cropID string) *struct {
|
||||
Nitrogen *float64 `json:"nitrogen,omitempty"`
|
||||
Phosphorus *float64 `json:"phosphorus,omitempty"`
|
||||
Potassium *float64 `json:"potassium,omitempty"`
|
||||
} {
|
||||
// Return dummy data or nil if unavailable
|
||||
if rand.Intn(10) < 7 { // 70% chance of having dummy data
|
||||
n := float64(50 + rand.Intn(40)) // 50-89
|
||||
p := float64(40 + rand.Intn(40)) // 40-79
|
||||
k := float64(45 + rand.Intn(40)) // 45-84
|
||||
return &struct {
|
||||
Nitrogen *float64 `json:"nitrogen,omitempty"`
|
||||
Phosphorus *float64 `json:"phosphorus,omitempty"`
|
||||
Potassium *float64 `json:"potassium,omitempty"`
|
||||
}{
|
||||
Nitrogen: &n,
|
||||
Phosphorus: &p,
|
||||
Potassium: &k,
|
||||
}
|
||||
}
|
||||
return nil // Simulate data not available
|
||||
}
|
||||
|
||||
// GetEnvironmentalData attempts to retrieve relevant environmental data.
|
||||
// TODO: Enhance this - Could query specific weather events for the crop location/timeframe.
|
||||
// Currently relies on potentially stale FarmAnalytics weather.
|
||||
func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalytics) (temp, humidity, wind, rain, sunlight, soilMoisture *float64) {
|
||||
// Initialize with nil
|
||||
temp, humidity, wind, rain, sunlight, soilMoisture = nil, nil, nil, nil, nil, nil
|
||||
|
||||
// Try to get from FarmAnalytics
|
||||
if farmAnalytics != nil && farmAnalytics.Weather != nil {
|
||||
temp = farmAnalytics.Weather.TempCelsius
|
||||
humidity = farmAnalytics.Weather.Humidity
|
||||
wind = farmAnalytics.Weather.WindSpeed
|
||||
rain = farmAnalytics.Weather.RainVolume1h
|
||||
// Note: Sunlight and SoilMoisture are not typically in basic WeatherData
|
||||
}
|
||||
|
||||
// Provide dummy values ONLY if still nil (ensures real data isn't overwritten)
|
||||
if temp == nil {
|
||||
t := float64(18 + rand.Intn(15)) // 18-32 C
|
||||
temp = &t
|
||||
}
|
||||
if humidity == nil {
|
||||
h := float64(40 + rand.Intn(50)) // 40-89 %
|
||||
humidity = &h
|
||||
}
|
||||
if wind == nil {
|
||||
w := float64(rand.Intn(15)) // 0-14 m/s
|
||||
wind = &w
|
||||
}
|
||||
if rain == nil {
|
||||
// Simulate less frequent rain
|
||||
r := 0.0
|
||||
if rand.Intn(10) < 2 { // 20% chance of rain
|
||||
r = float64(rand.Intn(5)) // 0-4 mm
|
||||
}
|
||||
rain = &r
|
||||
}
|
||||
if sunlight == nil {
|
||||
sl := float64(60 + rand.Intn(40)) // 60-99 %
|
||||
sunlight = &sl
|
||||
}
|
||||
if soilMoisture == nil {
|
||||
sm := float64(30 + rand.Intn(50)) // 30-79 %
|
||||
soilMoisture = &sm
|
||||
}
|
||||
|
||||
return // Named return values
|
||||
}
|
||||
52
backend/internal/services/weather/cached_fetcher.go
Normal file
52
backend/internal/services/weather/cached_fetcher.go
Normal file
@ -0,0 +1,52 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
type CachedWeatherFetcher struct {
|
||||
next domain.WeatherFetcher
|
||||
cache *cache.Cache
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewCachedWeatherFetcher(fetcher domain.WeatherFetcher, ttl time.Duration, cleanupInterval time.Duration, logger *slog.Logger) domain.WeatherFetcher {
|
||||
c := cache.New(ttl, cleanupInterval)
|
||||
return &CachedWeatherFetcher{
|
||||
next: fetcher,
|
||||
cache: c,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *CachedWeatherFetcher) GetCurrentWeatherByCoords(ctx context.Context, lat, lon float64) (*domain.WeatherData, error) {
|
||||
cacheKey := fmt.Sprintf("weather_coords_%.4f_%.4f", lat, lon)
|
||||
|
||||
if data, found := f.cache.Get(cacheKey); found {
|
||||
if weatherData, ok := data.(*domain.WeatherData); ok {
|
||||
return weatherData, nil
|
||||
}
|
||||
f.logger.Warn("Invalid data type found in weather cache", "key", cacheKey)
|
||||
}
|
||||
|
||||
f.logger.Debug("Cache miss for weather data", "key", cacheKey)
|
||||
|
||||
weatherData, err := f.next.GetCurrentWeatherByCoords(ctx, lat, lon)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if weatherData != nil {
|
||||
f.cache.Set(cacheKey, weatherData, cache.DefaultExpiration) // Uses the TTL set during cache creation
|
||||
f.logger.Debug("Stored fetched weather data in cache", "key", cacheKey)
|
||||
}
|
||||
|
||||
return weatherData, nil
|
||||
}
|
||||
154
backend/internal/services/weather/openweathermap_fetcher.go
Normal file
154
backend/internal/services/weather/openweathermap_fetcher.go
Normal file
@ -0,0 +1,154 @@
|
||||
// backend/internal/services/weather/openweathermap_fetcher.go
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
)
|
||||
|
||||
const openWeatherMapOneCallAPIURL = "https://api.openweathermap.org/data/3.0/onecall"
|
||||
|
||||
type openWeatherMapOneCallResponse struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Timezone string `json:"timezone"`
|
||||
TimezoneOffset int `json:"timezone_offset"`
|
||||
Current *struct {
|
||||
Dt int64 `json:"dt"` // Current time, Unix, UTC
|
||||
Sunrise int64 `json:"sunrise"`
|
||||
Sunset int64 `json:"sunset"`
|
||||
Temp float64 `json:"temp"` // Kelvin by default, 'units=metric' for Celsius
|
||||
FeelsLike float64 `json:"feels_like"` // Kelvin by default
|
||||
Pressure int `json:"pressure"` // hPa
|
||||
Humidity int `json:"humidity"` // %
|
||||
DewPoint float64 `json:"dew_point"`
|
||||
Uvi float64 `json:"uvi"`
|
||||
Clouds int `json:"clouds"` // %
|
||||
Visibility int `json:"visibility"` // meters
|
||||
WindSpeed float64 `json:"wind_speed"` // meter/sec by default
|
||||
WindDeg int `json:"wind_deg"`
|
||||
WindGust float64 `json:"wind_gust,omitempty"`
|
||||
Rain *struct {
|
||||
OneH float64 `json:"1h"` // Rain volume for the last 1 hour, mm
|
||||
} `json:"rain,omitempty"`
|
||||
Snow *struct {
|
||||
OneH float64 `json:"1h"` // Snow volume for the last 1 hour, mm
|
||||
} `json:"snow,omitempty"`
|
||||
Weather []struct {
|
||||
ID int `json:"id"`
|
||||
Main string `json:"main"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
} `json:"weather"`
|
||||
} `json:"current,omitempty"`
|
||||
// Minutely []...
|
||||
// Hourly []...
|
||||
// Daily []...
|
||||
// Alerts []...
|
||||
}
|
||||
|
||||
type OpenWeatherMapFetcher struct {
|
||||
apiKey string
|
||||
client *http.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewOpenWeatherMapFetcher(apiKey string, client *http.Client, logger *slog.Logger) domain.WeatherFetcher {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &OpenWeatherMapFetcher{
|
||||
apiKey: apiKey,
|
||||
client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, lat, lon float64) (*domain.WeatherData, error) {
|
||||
queryParams := url.Values{}
|
||||
queryParams.Set("lat", fmt.Sprintf("%.4f", lat))
|
||||
queryParams.Set("lon", fmt.Sprintf("%.4f", lon))
|
||||
queryParams.Set("appid", f.apiKey)
|
||||
queryParams.Set("units", "metric") // Request Celsius and m/s
|
||||
queryParams.Set("exclude", "minutely,hourly,daily,alerts") // Exclude parts we don't need now
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", openWeatherMapOneCallAPIURL, queryParams.Encode())
|
||||
f.logger.Debug("Fetching weather from OpenWeatherMap OneCall API", "url", fullURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
f.logger.Error("Failed to create OpenWeatherMap request", "error", err)
|
||||
return nil, fmt.Errorf("failed to create weather request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
f.logger.Error("Failed to execute OpenWeatherMap request", "url", fullURL, "error", err)
|
||||
return nil, fmt.Errorf("failed to fetch weather data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// TODO: Read resp.Body to get error message from OpenWeatherMap
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
f.logger.Error("OpenWeatherMap API returned non-OK status",
|
||||
"url", fullURL,
|
||||
"status_code", resp.StatusCode,
|
||||
"body", string(bodyBytes))
|
||||
return nil, fmt.Errorf("weather API request failed with status: %s", resp.Status)
|
||||
}
|
||||
|
||||
var owmResp openWeatherMapOneCallResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&owmResp); err != nil {
|
||||
f.logger.Error("Failed to decode OpenWeatherMap OneCall response", "error", err)
|
||||
return nil, fmt.Errorf("failed to decode weather response: %w", err)
|
||||
}
|
||||
|
||||
if owmResp.Current == nil {
|
||||
f.logger.Warn("OpenWeatherMap OneCall response missing 'current' weather data", "lat", lat, "lon", lon)
|
||||
return nil, fmt.Errorf("current weather data not found in API response")
|
||||
}
|
||||
current := owmResp.Current
|
||||
|
||||
if len(current.Weather) == 0 {
|
||||
f.logger.Warn("OpenWeatherMap response missing weather description details", "lat", lat, "lon", lon)
|
||||
return nil, fmt.Errorf("weather data description not found in response")
|
||||
}
|
||||
|
||||
// Create domain object using pointers for optional fields
|
||||
weatherData := &domain.WeatherData{} // Initialize empty struct first
|
||||
|
||||
// Assign values using pointers, checking for nil where appropriate
|
||||
weatherData.TempCelsius = ¤t.Temp
|
||||
humidityFloat := float64(current.Humidity)
|
||||
weatherData.Humidity = &humidityFloat
|
||||
weatherData.Description = ¤t.Weather[0].Description
|
||||
weatherData.Icon = ¤t.Weather[0].Icon
|
||||
weatherData.WindSpeed = ¤t.WindSpeed
|
||||
if current.Rain != nil {
|
||||
weatherData.RainVolume1h = ¤t.Rain.OneH
|
||||
}
|
||||
observedTime := time.Unix(current.Dt, 0).UTC()
|
||||
weatherData.ObservedAt = &observedTime
|
||||
now := time.Now().UTC()
|
||||
weatherData.WeatherLastUpdated = &now
|
||||
|
||||
f.logger.Debug("Successfully fetched weather data",
|
||||
"lat", lat,
|
||||
"lon", lon,
|
||||
"temp", *weatherData.TempCelsius,
|
||||
"description", *weatherData.Description)
|
||||
|
||||
return weatherData, nil
|
||||
}
|
||||
170
backend/internal/workers/weather_updater.go
Normal file
170
backend/internal/workers/weather_updater.go
Normal file
@ -0,0 +1,170 @@
|
||||
// backend/internal/workers/weather_updater.go
|
||||
package workers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type WeatherUpdater struct {
|
||||
farmRepo domain.FarmRepository
|
||||
weatherFetcher domain.WeatherFetcher
|
||||
eventPublisher domain.EventPublisher
|
||||
logger *slog.Logger
|
||||
fetchInterval time.Duration
|
||||
stopChan chan struct{} // Channel to signal stopping
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewWeatherUpdater(
|
||||
farmRepo domain.FarmRepository,
|
||||
weatherFetcher domain.WeatherFetcher,
|
||||
eventPublisher domain.EventPublisher,
|
||||
logger *slog.Logger,
|
||||
fetchInterval time.Duration,
|
||||
) *WeatherUpdater {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if fetchInterval <= 0 {
|
||||
fetchInterval = 15 * time.Minute
|
||||
}
|
||||
return &WeatherUpdater{
|
||||
farmRepo: farmRepo,
|
||||
weatherFetcher: weatherFetcher,
|
||||
eventPublisher: eventPublisher,
|
||||
logger: logger,
|
||||
fetchInterval: fetchInterval,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WeatherUpdater) Start(ctx context.Context) {
|
||||
w.logger.Info("Starting Weather Updater worker", "interval", w.fetchInterval)
|
||||
ticker := time.NewTicker(w.fetchInterval)
|
||||
|
||||
w.wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer w.wg.Done()
|
||||
defer ticker.Stop()
|
||||
w.logger.Info("Weather Updater goroutine started")
|
||||
|
||||
w.fetchAndUpdateAllFarms(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
w.logger.Info("Weather Updater tick: fetching weather data")
|
||||
w.fetchAndUpdateAllFarms(ctx)
|
||||
case <-w.stopChan:
|
||||
w.logger.Info("Weather Updater received stop signal, stopping...")
|
||||
return
|
||||
case <-ctx.Done():
|
||||
w.logger.Info("Weather Updater context cancelled, stopping...", "reason", ctx.Err())
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *WeatherUpdater) Stop() {
|
||||
w.logger.Info("Attempting to stop Weather Updater worker...")
|
||||
close(w.stopChan)
|
||||
w.wg.Wait()
|
||||
w.logger.Info("Weather Updater worker stopped")
|
||||
}
|
||||
|
||||
func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
|
||||
// Use a background context for the repository call if the main context might cancel prematurely
|
||||
// repoCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Example timeout
|
||||
// defer cancel()
|
||||
|
||||
// TODO: Need a GetAllFarms method in the FarmRepository or a way to efficiently get all farm locations.
|
||||
farms, err := w.farmRepo.GetByOwnerID(ctx, "") // !! REPLACE with a proper GetAll method !!
|
||||
if err != nil {
|
||||
w.logger.Error("Failed to get farms for weather update", "error", err)
|
||||
return
|
||||
}
|
||||
if len(farms) == 0 {
|
||||
w.logger.Info("No farms found to update weather for.")
|
||||
return
|
||||
}
|
||||
|
||||
w.logger.Info("Found farms for weather update", "count", len(farms))
|
||||
|
||||
var fetchWg sync.WaitGroup
|
||||
fetchCtx, cancelFetches := context.WithCancel(ctx)
|
||||
defer cancelFetches()
|
||||
|
||||
for _, farm := range farms {
|
||||
if farm.Lat == 0 && farm.Lon == 0 {
|
||||
w.logger.Warn("Skipping farm with zero coordinates", "farm_id", farm.UUID, "farm_name", farm.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
fetchWg.Add(1)
|
||||
go func(f domain.Farm) {
|
||||
defer fetchWg.Done()
|
||||
select {
|
||||
case <-fetchCtx.Done():
|
||||
return
|
||||
default:
|
||||
w.fetchAndPublishWeather(fetchCtx, f)
|
||||
}
|
||||
}(farm)
|
||||
}
|
||||
|
||||
fetchWg.Wait()
|
||||
w.logger.Info("Finished weather fetch cycle for farms", "count", len(farms))
|
||||
}
|
||||
|
||||
func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain.Farm) {
|
||||
weatherData, err := w.weatherFetcher.GetCurrentWeatherByCoords(ctx, farm.Lat, farm.Lon)
|
||||
if err != nil {
|
||||
w.logger.Error("Failed to fetch weather data", "farm_id", farm.UUID, "lat", farm.Lat, "lon", farm.Lon, "error", err)
|
||||
return
|
||||
}
|
||||
if weatherData == nil {
|
||||
w.logger.Warn("Received nil weather data without error", "farm_id", farm.UUID)
|
||||
return
|
||||
}
|
||||
|
||||
payloadMap := map[string]interface{}{
|
||||
"farm_id": farm.UUID,
|
||||
"lat": farm.Lat,
|
||||
"lon": farm.Lon,
|
||||
"temp_celsius": weatherData.TempCelsius,
|
||||
"humidity": weatherData.Humidity,
|
||||
"description": weatherData.Description,
|
||||
"icon": weatherData.Icon,
|
||||
"wind_speed": weatherData.WindSpeed,
|
||||
"rain_volume_1h": weatherData.RainVolume1h,
|
||||
"observed_at": weatherData.ObservedAt,
|
||||
"weather_last_updated": weatherData.WeatherLastUpdated,
|
||||
}
|
||||
|
||||
event := domain.Event{
|
||||
ID: uuid.NewString(),
|
||||
Type: "weather.updated",
|
||||
Source: "weather-updater-worker",
|
||||
Timestamp: time.Now().UTC(),
|
||||
AggregateID: farm.UUID,
|
||||
Payload: payloadMap,
|
||||
}
|
||||
|
||||
pubCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = w.eventPublisher.Publish(pubCtx, event)
|
||||
if err != nil {
|
||||
w.logger.Error("Failed to publish weather.updated event", "farm_id", farm.UUID, "event_id", event.ID, "error", err)
|
||||
} else {
|
||||
w.logger.Debug("Published weather.updated event", "farm_id", farm.UUID, "event_id", event.ID)
|
||||
}
|
||||
}
|
||||
@ -7,4 +7,7 @@ run:
|
||||
go run cmd/forfarm/main.go API
|
||||
|
||||
migrate:
|
||||
go run cmd/forfarm/main.go migrate
|
||||
go run cmd/forfarm/main.go migrate
|
||||
|
||||
rollback:
|
||||
go run cmd/forfarm/main.go rollback $(VERSION)
|
||||
@ -10,4 +10,10 @@ CREATE TABLE users (
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_users_uuid ON users(uuid);
|
||||
CREATE UNIQUE INDEX idx_users_uuid ON users(uuid);
|
||||
CREATE UNIQUE INDEX idx_users_email ON users(email); -- Added unique constraint for email
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_users_email;
|
||||
DROP INDEX IF EXISTS idx_users_uuid;
|
||||
DROP TABLE IF EXISTS users;
|
||||
@ -12,7 +12,7 @@ CREATE TABLE soil_conditions (
|
||||
CREATE TABLE harvest_units (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
);
|
||||
|
||||
CREATE TABLE plants (
|
||||
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@ -45,7 +45,7 @@ CREATE TABLE farms (
|
||||
name TEXT NOT NULL,
|
||||
lat DOUBLE PRECISION[] NOT NULL,
|
||||
lon DOUBLE PRECISION[] NOT NULL,
|
||||
plant_types UUID[],
|
||||
plant_types UUID[], -- This column will be dropped in the next migration
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
owner_id UUID NOT NULL,
|
||||
@ -55,10 +55,10 @@ CREATE TABLE farms (
|
||||
CREATE TABLE croplands (
|
||||
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- Consider creating a status table if values are fixed
|
||||
priority INT NOT NULL,
|
||||
land_size DOUBLE PRECISION NOT NULL,
|
||||
growth_stage TEXT NOT NULL,
|
||||
growth_stage TEXT NOT NULL, -- Consider creating a growth_stage table
|
||||
plant_id UUID NOT NULL,
|
||||
farm_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
@ -66,3 +66,12 @@ CREATE TABLE croplands (
|
||||
CONSTRAINT fk_cropland_farm FOREIGN KEY (farm_id) REFERENCES farms(uuid) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_cropland_plant FOREIGN KEY (plant_id) REFERENCES plants(uuid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS croplands;
|
||||
DROP TABLE IF EXISTS farms;
|
||||
DROP TABLE IF EXISTS plants;
|
||||
DROP TABLE IF EXISTS harvest_units;
|
||||
DROP TABLE IF EXISTS soil_conditions;
|
||||
DROP TABLE IF EXISTS light_profiles;
|
||||
@ -1,2 +1,8 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE farms DROP COLUMN plant_types;
|
||||
-- This column was initially created in 00002 but deemed unnecessary.
|
||||
ALTER TABLE farms DROP COLUMN IF EXISTS plant_types; -- Use IF EXISTS for safety
|
||||
|
||||
-- +goose Down
|
||||
-- Add the column back if rolling back.
|
||||
ALTER TABLE farms
|
||||
ADD COLUMN plant_types UUID[];
|
||||
@ -1,14 +1,16 @@
|
||||
-- +goose Up
|
||||
-- Creates the initial inventory_items table.
|
||||
-- Note: 'category', 'type', 'unit', and 'status' columns will be modified/replaced by later migrations.
|
||||
CREATE TABLE inventory_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
category TEXT NOT NULL, -- To be replaced by category_id
|
||||
type TEXT NOT NULL, -- To be dropped
|
||||
quantity DOUBLE PRECISION NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
unit TEXT NOT NULL, -- To be replaced by unit_id
|
||||
date_added TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- To be replaced by status_id
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_inventory_items_user FOREIGN KEY (user_id) REFERENCES users(uuid) ON DELETE CASCADE
|
||||
@ -18,3 +20,6 @@ CREATE TABLE inventory_items (
|
||||
CREATE INDEX idx_inventory_items_user_id ON inventory_items(user_id);
|
||||
CREATE INDEX idx_inventory_items_user_category ON inventory_items(user_id, category);
|
||||
CREATE INDEX idx_inventory_items_user_status ON inventory_items(user_id, status);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS inventory_items; -- Indexes are dropped automatically
|
||||
79
backend/migrations/00005_create_analytic_event_table.sql
Normal file
79
backend/migrations/00005_create_analytic_event_table.sql
Normal file
@ -0,0 +1,79 @@
|
||||
-- +goose Up
|
||||
-- Create analytics_events table to store all events
|
||||
CREATE TABLE IF NOT EXISTS public.analytics_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
farm_id UUID NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
event_data JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_analytics_farm FOREIGN KEY (farm_id) REFERENCES public.farms(uuid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create index for faster queries
|
||||
CREATE INDEX idx_analytics_events_farm_id ON public.analytics_events(farm_id);
|
||||
CREATE INDEX idx_analytics_events_event_type ON public.analytics_events(event_type);
|
||||
CREATE INDEX idx_analytics_events_created_at ON public.analytics_events(created_at);
|
||||
|
||||
-- Create a simple materialized view for farm analytics (Version 1)
|
||||
CREATE MATERIALIZED VIEW public.farm_analytics_view AS
|
||||
SELECT
|
||||
f.uuid AS farm_id,
|
||||
f.name AS farm_name,
|
||||
f.owner_id,
|
||||
-- Columns added in migration 00006 will be added to the view later
|
||||
-- f.farm_type,
|
||||
-- f.total_size,
|
||||
f.created_at,
|
||||
f.updated_at,
|
||||
COUNT(ae.id) AS total_events,
|
||||
MAX(ae.created_at) AS last_event_at
|
||||
FROM
|
||||
public.farms f
|
||||
LEFT JOIN
|
||||
public.analytics_events ae ON f.uuid = ae.farm_id
|
||||
GROUP BY
|
||||
f.uuid, f.name, f.owner_id, f.created_at, f.updated_at;
|
||||
|
||||
-- Create index for faster queries on the view
|
||||
-- UNIQUE index is required for CONCURRENTLY refresh
|
||||
CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id);
|
||||
CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id);
|
||||
|
||||
-- Create function to refresh the materialized view CONCURRENTLY
|
||||
-- +goose StatementBegin
|
||||
CREATE OR REPLACE FUNCTION public.refresh_farm_analytics_view()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Use CONCURRENTLY to avoid locking the view during refresh
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY public.farm_analytics_view;
|
||||
RETURN NULL; -- result is ignored since this is an AFTER trigger
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- Create trigger to refresh the view when new events are added
|
||||
CREATE TRIGGER refresh_farm_analytics_view_trigger_events
|
||||
AFTER INSERT ON public.analytics_events
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION public.refresh_farm_analytics_view();
|
||||
|
||||
-- Create trigger to refresh the view when farms are updated
|
||||
-- Note: This trigger will need to be updated if the view definition changes significantly
|
||||
CREATE TRIGGER refresh_farm_analytics_view_trigger_farms
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.farms
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION public.refresh_farm_analytics_view();
|
||||
|
||||
-- +goose Down
|
||||
-- Drop triggers first
|
||||
DROP TRIGGER IF EXISTS refresh_farm_analytics_view_trigger_events ON public.analytics_events;
|
||||
DROP TRIGGER IF EXISTS refresh_farm_analytics_view_trigger_farms ON public.farms;
|
||||
|
||||
-- Drop function
|
||||
DROP FUNCTION IF EXISTS public.refresh_farm_analytics_view() CASCADE;
|
||||
|
||||
-- Drop materialized view
|
||||
DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view CASCADE;
|
||||
|
||||
-- Drop table with CASCADE to ensure all dependencies are removed
|
||||
DROP TABLE IF EXISTS public.analytics_events CASCADE;
|
||||
@ -1,5 +0,0 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE inventory_status (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
@ -1,15 +0,0 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN status_id INT;
|
||||
|
||||
UPDATE inventory_items
|
||||
SET status_id = (SELECT id FROM inventory_status WHERE name = inventory_items.status);
|
||||
|
||||
ALTER TABLE inventory_items
|
||||
DROP COLUMN status;
|
||||
|
||||
ALTER TABLE inventory_items
|
||||
ADD CONSTRAINT fk_inventory_items_status FOREIGN KEY (status_id) REFERENCES inventory_status(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_inventory_items_status_id ON inventory_items(status_id);
|
||||
|
||||
25
backend/migrations/00006_update_farm_table.sql
Normal file
25
backend/migrations/00006_update_farm_table.sql
Normal file
@ -0,0 +1,25 @@
|
||||
-- +goose Up
|
||||
-- Add new columns for farm details
|
||||
ALTER TABLE farms
|
||||
ADD COLUMN farm_type TEXT,
|
||||
ADD COLUMN total_size TEXT; -- Consider NUMERIC or DOUBLE PRECISION if it's always a number
|
||||
|
||||
-- Change lat/lon from array to single value
|
||||
-- Assumes the first element of the array was the intended value
|
||||
ALTER TABLE farms
|
||||
ALTER COLUMN lat TYPE DOUBLE PRECISION USING lat[1],
|
||||
ALTER COLUMN lon TYPE DOUBLE PRECISION USING lon[1];
|
||||
|
||||
-- Note: The farm_analytics_view created in 00005 does not yet include these new columns.
|
||||
-- Subsequent migrations will update the view.
|
||||
|
||||
-- +goose Down
|
||||
-- Revert lat/lon change
|
||||
ALTER TABLE farms
|
||||
ALTER COLUMN lat TYPE DOUBLE PRECISION[] USING ARRAY[lat],
|
||||
ALTER COLUMN lon TYPE DOUBLE PRECISION[] USING ARRAY[lon];
|
||||
|
||||
-- Remove added columns
|
||||
ALTER TABLE farms
|
||||
DROP COLUMN IF EXISTS farm_type,
|
||||
DROP COLUMN IF EXISTS total_size;
|
||||
@ -1,7 +1,18 @@
|
||||
-- +goose Up
|
||||
-- Insert default statuses into the inventory_status table
|
||||
INSERT INTO inventory_status (name)
|
||||
VALUES
|
||||
('In Stock'),
|
||||
('Low Stock'),
|
||||
('Out Of Stock');
|
||||
-- Create the lookup table for inventory item statuses
|
||||
CREATE TABLE inventory_status (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Insert common default statuses needed for the next migration (00008)
|
||||
INSERT INTO inventory_status (name) VALUES
|
||||
('In Stock'),
|
||||
('Low Stock'),
|
||||
('Out of Stock'),
|
||||
('Expired'),
|
||||
('Reserved');
|
||||
-- Add any other statuses that might exist in the old 'status' text column
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS inventory_status;
|
||||
@ -1,70 +0,0 @@
|
||||
-- +goose Up
|
||||
-- Step 1: Create inventory_category table
|
||||
CREATE TABLE inventory_category (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Step 2: Insert sample categories
|
||||
INSERT INTO inventory_category (name)
|
||||
VALUES
|
||||
('Seeds'),
|
||||
('Tools'),
|
||||
('Chemicals');
|
||||
|
||||
-- Step 3: Add category_id column to inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN category_id INT;
|
||||
|
||||
-- Step 4: Link inventory_items to inventory_category
|
||||
ALTER TABLE inventory_items
|
||||
ADD CONSTRAINT fk_inventory_category FOREIGN KEY (category_id) REFERENCES inventory_category(id) ON DELETE SET NULL;
|
||||
|
||||
-- Step 5: Remove old columns (type, category, unit) from inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
DROP COLUMN type,
|
||||
DROP COLUMN category,
|
||||
DROP COLUMN unit;
|
||||
|
||||
-- Step 6: Add unit_id column to inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN unit_id INT;
|
||||
|
||||
-- Step 7: Link inventory_items to harvest_units
|
||||
ALTER TABLE inventory_items
|
||||
ADD CONSTRAINT fk_inventory_unit FOREIGN KEY (unit_id) REFERENCES harvest_units(id) ON DELETE SET NULL;
|
||||
|
||||
-- Step 8: Insert new unit values into harvest_units
|
||||
INSERT INTO harvest_units (name)
|
||||
VALUES
|
||||
('Tonne'),
|
||||
('KG');
|
||||
|
||||
-- +goose Down
|
||||
-- Reverse Step 8: Remove inserted unit values
|
||||
DELETE FROM harvest_units WHERE name IN ('Tonne', 'KG');
|
||||
|
||||
-- Reverse Step 7: Remove the foreign key constraint
|
||||
ALTER TABLE inventory_items
|
||||
DROP CONSTRAINT fk_inventory_unit;
|
||||
|
||||
-- Reverse Step 6: Remove unit_id column from inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
DROP COLUMN unit_id;
|
||||
|
||||
-- Reverse Step 5: Add back type, category, and unit columns
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN type TEXT NOT NULL,
|
||||
ADD COLUMN category TEXT NOT NULL,
|
||||
ADD COLUMN unit TEXT NOT NULL;
|
||||
|
||||
-- Reverse Step 4: Remove foreign key constraint from inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
DROP CONSTRAINT fk_inventory_category;
|
||||
|
||||
-- Reverse Step 3: Remove category_id column from inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
DROP COLUMN category_id;
|
||||
|
||||
-- Reverse Step 2: Drop inventory_category table
|
||||
DROP TABLE inventory_category;
|
||||
46
backend/migrations/00008_modify_inventory_table.sql
Normal file
46
backend/migrations/00008_modify_inventory_table.sql
Normal file
@ -0,0 +1,46 @@
|
||||
-- +goose Up
|
||||
-- Add the status_id column to link to the new inventory_status table
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN status_id INT;
|
||||
|
||||
-- Update the new status_id based on the old text status column
|
||||
-- This relies on the inventory_status table being populated (done in 00007)
|
||||
UPDATE inventory_items inv
|
||||
SET status_id = (SELECT id FROM inventory_status stat WHERE stat.name = inv.status)
|
||||
WHERE EXISTS (SELECT 1 FROM inventory_status stat WHERE stat.name = inv.status);
|
||||
-- Handle cases where the old status might not be in the new table (optional: set to a default or log)
|
||||
-- UPDATE inventory_items SET status_id = <default_status_id> WHERE status_id IS NULL;
|
||||
|
||||
-- Drop the old text status column
|
||||
ALTER TABLE inventory_items
|
||||
DROP COLUMN status;
|
||||
|
||||
-- Add the foreign key constraint
|
||||
-- Make status_id NOT NULL if every item must have a status
|
||||
ALTER TABLE inventory_items
|
||||
ADD CONSTRAINT fk_inventory_items_status FOREIGN KEY (status_id) REFERENCES inventory_status(id) ON DELETE SET NULL; -- Or ON DELETE RESTRICT
|
||||
|
||||
-- Create an index on the new foreign key column
|
||||
CREATE INDEX idx_inventory_items_status_id ON inventory_items(status_id);
|
||||
|
||||
|
||||
-- +goose Down
|
||||
-- Drop the index
|
||||
DROP INDEX IF EXISTS idx_inventory_items_status_id;
|
||||
|
||||
-- Drop the foreign key constraint
|
||||
ALTER TABLE inventory_items
|
||||
DROP CONSTRAINT IF EXISTS fk_inventory_items_status;
|
||||
|
||||
-- Add the old status column back
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN status TEXT; -- Make NOT NULL if it was originally
|
||||
|
||||
-- Attempt to restore the status text from status_id (data loss if status was deleted)
|
||||
UPDATE inventory_items inv
|
||||
SET status = (SELECT name FROM inventory_status stat WHERE stat.id = inv.status_id)
|
||||
WHERE inv.status_id IS NOT NULL;
|
||||
|
||||
-- Drop the status_id column
|
||||
ALTER TABLE inventory_items
|
||||
DROP COLUMN status_id;
|
||||
119
backend/migrations/00009_add_farm_analytic_view.sql
Normal file
119
backend/migrations/00009_add_farm_analytic_view.sql
Normal file
@ -0,0 +1,119 @@
|
||||
-- +goose Up
|
||||
-- Description: Recreates farm_analytics_view (Version 2) to include aggregated data
|
||||
-- from analytics_events and new columns from the farms table.
|
||||
|
||||
-- Drop the existing materialized view (from migration 00005)
|
||||
DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view;
|
||||
|
||||
-- Recreate the materialized view with aggregated data
|
||||
CREATE MATERIALIZED VIEW public.farm_analytics_view AS
|
||||
SELECT
|
||||
f.uuid AS farm_id,
|
||||
f.name AS farm_name,
|
||||
f.owner_id,
|
||||
f.farm_type, -- Added in 00006
|
||||
f.total_size, -- Added in 00006
|
||||
-- Determine last update time based on farm update or latest event
|
||||
COALESCE(
|
||||
(SELECT MAX(ae_max.created_at) FROM public.analytics_events ae_max WHERE ae_max.farm_id = f.uuid),
|
||||
f.updated_at
|
||||
) AS last_updated,
|
||||
|
||||
-- Weather data aggregation (Example: Average Temp/Humidity, Forecast List)
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'temperature_avg', AVG((ae_w.event_data->>'temperature')::float) FILTER (WHERE ae_w.event_data ? 'temperature'),
|
||||
'humidity_avg', AVG((ae_w.event_data->>'humidity')::float) FILTER (WHERE ae_w.event_data ? 'humidity'),
|
||||
'forecasts', jsonb_agg(ae_w.event_data->'forecast') FILTER (WHERE ae_w.event_data ? 'forecast')
|
||||
)
|
||||
FROM analytics_events ae_w
|
||||
WHERE ae_w.farm_id = f.uuid AND ae_w.event_type = 'weather.updated' -- Ensure event type is correct
|
||||
-- GROUP BY ae_w.farm_id -- Not needed inside subquery selecting for one farm
|
||||
) AS weather_data,
|
||||
|
||||
-- Inventory data aggregation (Example: Item List, Last Update)
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'items', COALESCE(jsonb_agg(ae_i.event_data->'items' ORDER BY (ae_i.event_data->>'timestamp') DESC) FILTER (WHERE ae_i.event_data ? 'items'), '[]'::jsonb),
|
||||
'last_updated', MAX(ae_i.created_at)
|
||||
)
|
||||
FROM analytics_events ae_i
|
||||
WHERE ae_i.farm_id = f.uuid AND ae_i.event_type = 'inventory.updated' -- Ensure event type is correct
|
||||
-- GROUP BY ae_i.farm_id
|
||||
) AS inventory_data,
|
||||
|
||||
-- Plant health data aggregation (Example: Latest Status, Issues List)
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'status', MAX(ae_p.event_data->>'status'), -- MAX works on text, gets latest alphabetically if not timestamped
|
||||
'issues', COALESCE(jsonb_agg(ae_p.event_data->'issues') FILTER (WHERE ae_p.event_data ? 'issues'), '[]'::jsonb)
|
||||
-- Consider adding 'last_updated': MAX(ae_p.created_at)
|
||||
)
|
||||
FROM analytics_events ae_p
|
||||
WHERE ae_p.farm_id = f.uuid AND ae_p.event_type = 'plant_health.updated' -- Ensure event type is correct
|
||||
-- GROUP BY ae_p.farm_id
|
||||
) AS plant_health_data,
|
||||
|
||||
-- Financial data aggregation (Example: Sums)
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'revenue', SUM((ae_f.event_data->>'revenue')::float) FILTER (WHERE ae_f.event_data ? 'revenue'),
|
||||
'expenses', SUM((ae_f.event_data->>'expenses')::float) FILTER (WHERE ae_f.event_data ? 'expenses'),
|
||||
'profit', SUM((ae_f.event_data->>'profit')::float) FILTER (WHERE ae_f.event_data ? 'profit')
|
||||
-- Consider adding 'last_updated': MAX(ae_f.created_at)
|
||||
)
|
||||
FROM analytics_events ae_f
|
||||
WHERE ae_f.farm_id = f.uuid AND ae_f.event_type = 'financial.updated' -- Ensure event type is correct
|
||||
-- GROUP BY ae_f.farm_id
|
||||
) AS financial_data,
|
||||
|
||||
-- Production data aggregation (Example: Sum Yield, Latest Forecast)
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'yield_total', SUM((ae_pr.event_data->>'yield')::float) FILTER (WHERE ae_pr.event_data ? 'yield'),
|
||||
'forecast_latest', MAX(ae_pr.event_data->>'forecast') FILTER (WHERE ae_pr.event_data ? 'forecast') -- MAX on text
|
||||
-- Consider adding 'last_updated': MAX(ae_pr.created_at)
|
||||
)
|
||||
FROM analytics_events ae_pr
|
||||
WHERE ae_pr.farm_id = f.uuid AND ae_pr.event_type = 'production.updated' -- Ensure event type is correct
|
||||
-- GROUP BY ae_pr.farm_id
|
||||
) AS production_data
|
||||
|
||||
FROM
|
||||
public.farms f; -- No need for LEFT JOIN and GROUP BY on the main query for this structure
|
||||
|
||||
-- Recreate indexes for faster queries on the new view structure
|
||||
CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id);
|
||||
CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id);
|
||||
|
||||
-- The refresh function and triggers from migration 00005 should still work
|
||||
-- as they target the view by name ('public.farm_analytics_view').
|
||||
|
||||
-- +goose Down
|
||||
-- Drop the recreated materialized view
|
||||
DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view;
|
||||
|
||||
-- Restore the original simple materialized view (from migration 00005)
|
||||
CREATE MATERIALIZED VIEW public.farm_analytics_view AS
|
||||
SELECT
|
||||
f.uuid AS farm_id,
|
||||
f.name AS farm_name,
|
||||
f.owner_id,
|
||||
-- farm_type and total_size did not exist in the view definition from 00005
|
||||
f.created_at,
|
||||
f.updated_at,
|
||||
COUNT(ae.id) AS total_events,
|
||||
MAX(ae.created_at) AS last_event_at
|
||||
FROM
|
||||
public.farms f
|
||||
LEFT JOIN
|
||||
public.analytics_events ae ON f.uuid = ae.farm_id
|
||||
GROUP BY
|
||||
f.uuid, f.name, f.owner_id, f.created_at, f.updated_at;
|
||||
|
||||
-- Recreate indexes for the restored view
|
||||
CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id);
|
||||
CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id);
|
||||
|
||||
-- The refresh function and triggers from 00005 are assumed to still exist
|
||||
-- and will target this restored view definition.
|
||||
16
backend/migrations/00010_update_crop_table_geo.sql
Normal file
16
backend/migrations/00010_update_crop_table_geo.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- Add a column to store geographical features (marker or polygon) for a cropland.
|
||||
-- Example JSON structure:
|
||||
-- {"type": "marker", "position": {"lat": 13.84, "lng": 100.48}}
|
||||
-- or
|
||||
-- {"type": "polygon", "path": [{"lat": 13.81, "lng": 100.40}, ...]}
|
||||
ALTER TABLE croplands
|
||||
ADD COLUMN geo_feature JSONB;
|
||||
|
||||
-- Consider adding a GIN index if querying within the JSON often
|
||||
-- CREATE INDEX idx_croplands_geo_feature ON croplands USING GIN (geo_feature);
|
||||
|
||||
-- +goose Down
|
||||
-- Remove the geo_feature column
|
||||
ALTER TABLE croplands
|
||||
DROP COLUMN IF EXISTS geo_feature;
|
||||
157
backend/migrations/00011_update_analytics_view.sql
Normal file
157
backend/migrations/00011_update_analytics_view.sql
Normal file
@ -0,0 +1,157 @@
|
||||
-- +goose Up
|
||||
-- Description: Updates the farm_analytics_view (Version 3) to remove financial data
|
||||
-- and fetch the *latest* weather data instead of aggregating.
|
||||
|
||||
-- Drop the existing view (from migration 00009)
|
||||
DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view;
|
||||
|
||||
-- Recreate the materialized view with updated logic
|
||||
CREATE MATERIALIZED VIEW public.farm_analytics_view AS
|
||||
SELECT
|
||||
f.uuid AS farm_id,
|
||||
f.name AS farm_name,
|
||||
f.owner_id,
|
||||
f.farm_type,
|
||||
f.total_size,
|
||||
-- Determine last update time based on farm update or latest event
|
||||
COALESCE(
|
||||
(SELECT MAX(ae_max.created_at) FROM public.analytics_events ae_max WHERE ae_max.farm_id = f.uuid),
|
||||
f.updated_at
|
||||
) AS last_updated,
|
||||
|
||||
-- Weather data: Select the *latest* 'weather.updated' event data for the farm
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'last_updated', latest_weather.created_at, -- Use the event timestamp
|
||||
'temperature', (latest_weather.event_data->>'temperature')::float,
|
||||
'humidity', (latest_weather.event_data->>'humidity')::float,
|
||||
'rainfall', (latest_weather.event_data->>'rainfall')::float, -- Assuming 'rainfall' exists
|
||||
'wind_speed', (latest_weather.event_data->>'wind_speed')::float,
|
||||
'weather_status', latest_weather.event_data->>'weather_status', -- Assuming 'weather_status' exists
|
||||
'alert_level', latest_weather.event_data->>'alert_level', -- Assuming 'alert_level' exists
|
||||
'forecast_summary', latest_weather.event_data->>'forecast_summary' -- Assuming 'forecast_summary' exists
|
||||
-- Add more fields here, ensuring they exist in your 'weather.updated' event_data JSON
|
||||
)
|
||||
FROM (
|
||||
-- Find the most recent weather event for this farm
|
||||
SELECT ae_w.event_data, ae_w.created_at
|
||||
FROM public.analytics_events ae_w
|
||||
WHERE ae_w.farm_id = f.uuid AND ae_w.event_type = 'weather.updated' -- Make sure event_type is correct
|
||||
ORDER BY ae_w.created_at DESC
|
||||
LIMIT 1
|
||||
) AS latest_weather
|
||||
) AS weather_data, -- This will be NULL if no 'weather.updated' event exists for the farm
|
||||
|
||||
-- Inventory data aggregation (Keep logic from V2 or refine)
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'items', COALESCE(jsonb_agg(ae_i.event_data->'items' ORDER BY (ae_i.event_data->>'timestamp') DESC) FILTER (WHERE ae_i.event_data ? 'items'), '[]'::jsonb),
|
||||
'last_updated', MAX(ae_i.created_at)
|
||||
)
|
||||
FROM analytics_events ae_i
|
||||
WHERE ae_i.farm_id = f.uuid AND ae_i.event_type = 'inventory.updated' -- Ensure event type is correct
|
||||
) AS inventory_data,
|
||||
|
||||
-- Plant health data aggregation (Keep logic from V2 or refine)
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'status', MAX(ae_p.event_data->>'status'),
|
||||
'issues', COALESCE(jsonb_agg(ae_p.event_data->'issues') FILTER (WHERE ae_p.event_data ? 'issues'), '[]'::jsonb),
|
||||
'last_updated', MAX(ae_p.created_at)
|
||||
)
|
||||
FROM analytics_events ae_p
|
||||
WHERE ae_p.farm_id = f.uuid AND ae_p.event_type = 'plant_health.updated' -- Ensure event type is correct
|
||||
) AS plant_health_data,
|
||||
|
||||
-- Financial data aggregation -- REMOVED --
|
||||
|
||||
-- Production data aggregation (Keep logic from V2 or refine)
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'yield_total', SUM((ae_pr.event_data->>'yield')::float) FILTER (WHERE ae_pr.event_data ? 'yield'),
|
||||
'forecast_latest', MAX(ae_pr.event_data->>'forecast') FILTER (WHERE ae_pr.event_data ? 'forecast'),
|
||||
'last_updated', MAX(ae_pr.created_at)
|
||||
)
|
||||
FROM analytics_events ae_pr
|
||||
WHERE ae_pr.farm_id = f.uuid AND ae_pr.event_type = 'production.updated' -- Ensure event type is correct
|
||||
) AS production_data
|
||||
|
||||
FROM
|
||||
public.farms f;
|
||||
|
||||
-- Recreate indexes for faster queries on the new view structure
|
||||
CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id);
|
||||
CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id);
|
||||
|
||||
-- The refresh function and triggers from migration 00005 should still work.
|
||||
|
||||
-- +goose Down
|
||||
-- Revert to the previous version (from migration 00009)
|
||||
-- Drop the modified view
|
||||
DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view;
|
||||
|
||||
-- Recreate the view from migration 00009 (including financial data and aggregated weather)
|
||||
CREATE MATERIALIZED VIEW public.farm_analytics_view AS
|
||||
SELECT
|
||||
f.uuid AS farm_id,
|
||||
f.name AS farm_name,
|
||||
f.owner_id,
|
||||
f.farm_type,
|
||||
f.total_size,
|
||||
COALESCE(
|
||||
(SELECT MAX(ae_max.created_at) FROM public.analytics_events ae_max WHERE ae_max.farm_id = f.uuid),
|
||||
f.updated_at
|
||||
) AS last_updated,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'temperature_avg', AVG((ae_w.event_data->>'temperature')::float) FILTER (WHERE ae_w.event_data ? 'temperature'),
|
||||
'humidity_avg', AVG((ae_w.event_data->>'humidity')::float) FILTER (WHERE ae_w.event_data ? 'humidity'),
|
||||
'forecasts', jsonb_agg(ae_w.event_data->'forecast') FILTER (WHERE ae_w.event_data ? 'forecast')
|
||||
)
|
||||
FROM analytics_events ae_w
|
||||
WHERE ae_w.farm_id = f.uuid AND ae_w.event_type = 'weather.updated'
|
||||
) AS weather_data,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'items', COALESCE(jsonb_agg(ae_i.event_data->'items' ORDER BY (ae_i.event_data->>'timestamp') DESC) FILTER (WHERE ae_i.event_data ? 'items'), '[]'::jsonb),
|
||||
'last_updated', MAX(ae_i.created_at)
|
||||
)
|
||||
FROM analytics_events ae_i
|
||||
WHERE ae_i.farm_id = f.uuid AND ae_i.event_type = 'inventory.updated'
|
||||
) AS inventory_data,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'status', MAX(ae_p.event_data->>'status'),
|
||||
'issues', COALESCE(jsonb_agg(ae_p.event_data->'issues') FILTER (WHERE ae_p.event_data ? 'issues'), '[]'::jsonb),
|
||||
'last_updated', MAX(ae_p.created_at)
|
||||
)
|
||||
FROM analytics_events ae_p
|
||||
WHERE ae_p.farm_id = f.uuid AND ae_p.event_type = 'plant_health.updated'
|
||||
) AS plant_health_data,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'revenue', SUM((ae_f.event_data->>'revenue')::float) FILTER (WHERE ae_f.event_data ? 'revenue'),
|
||||
'expenses', SUM((ae_f.event_data->>'expenses')::float) FILTER (WHERE ae_f.event_data ? 'expenses'),
|
||||
'profit', SUM((ae_f.event_data->>'profit')::float) FILTER (WHERE ae_f.event_data ? 'profit'),
|
||||
'last_updated', MAX(ae_f.created_at)
|
||||
)
|
||||
FROM analytics_events ae_f
|
||||
WHERE ae_f.farm_id = f.uuid AND ae_f.event_type = 'financial.updated'
|
||||
) AS financial_data,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'yield_total', SUM((ae_pr.event_data->>'yield')::float) FILTER (WHERE ae_pr.event_data ? 'yield'),
|
||||
'forecast_latest', MAX(ae_pr.event_data->>'forecast') FILTER (WHERE ae_pr.event_data ? 'forecast'),
|
||||
'last_updated', MAX(ae_pr.created_at)
|
||||
)
|
||||
FROM analytics_events ae_pr
|
||||
WHERE ae_pr.farm_id = f.uuid AND ae_pr.event_type = 'production.updated'
|
||||
) AS production_data
|
||||
FROM
|
||||
public.farms f;
|
||||
|
||||
-- Recreate indexes for the V2 view structure
|
||||
CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id);
|
||||
CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id);
|
||||
|
||||
-- The refresh function and triggers from 00005 are assumed to still exist.
|
||||
64
backend/migrations/00012_create_crop_analytics_view.sql
Normal file
64
backend/migrations/00012_create_crop_analytics_view.sql
Normal file
@ -0,0 +1,64 @@
|
||||
-- +goose Up
|
||||
-- Description: Creates a materialized view for crop-level analytics,
|
||||
-- pulling data directly from croplands and plants tables.
|
||||
|
||||
CREATE MATERIALIZED VIEW public.crop_analytics_view AS
|
||||
SELECT
|
||||
c.uuid AS crop_id,
|
||||
c.name AS crop_name,
|
||||
c.farm_id,
|
||||
p.name AS plant_name,
|
||||
p.variety AS variety, -- Include variety from plants table
|
||||
c.status AS current_status,
|
||||
c.growth_stage,
|
||||
c.land_size,
|
||||
c.geo_feature, -- Include geo_feature added in 00010
|
||||
c.updated_at AS last_updated -- Use cropland's updated_at as the primary refresh indicator
|
||||
-- Add columns here if CropAnalytics struct includes more fields derived directly
|
||||
-- from croplands or plants tables. Event-derived data would need different handling.
|
||||
FROM
|
||||
public.croplands c
|
||||
JOIN
|
||||
public.plants p ON c.plant_id = p.uuid;
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE UNIQUE INDEX idx_crop_analytics_view_crop_id ON public.crop_analytics_view(crop_id);
|
||||
CREATE INDEX idx_crop_analytics_view_farm_id ON public.crop_analytics_view(farm_id);
|
||||
CREATE INDEX idx_crop_analytics_view_plant_name ON public.crop_analytics_view(plant_name); -- Added index
|
||||
|
||||
-- Create a dedicated function to refresh this new view
|
||||
-- +goose StatementBegin
|
||||
CREATE OR REPLACE FUNCTION public.refresh_crop_analytics_view()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Use CONCURRENTLY to avoid locking the view during refresh
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY public.crop_analytics_view;
|
||||
RETURN NULL; -- result is ignored since this is an AFTER trigger
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- Create triggers to refresh the view when underlying data changes
|
||||
-- Trigger on Croplands table changes
|
||||
CREATE TRIGGER refresh_crop_analytics_trigger_croplands
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.croplands
|
||||
FOR EACH STATEMENT -- Refresh once per statement that modifies the table
|
||||
EXECUTE FUNCTION public.refresh_crop_analytics_view();
|
||||
|
||||
-- Trigger on Plants table changes (e.g., if plant name/variety is updated)
|
||||
CREATE TRIGGER refresh_crop_analytics_trigger_plants
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.plants
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION public.refresh_crop_analytics_view();
|
||||
|
||||
|
||||
-- +goose Down
|
||||
-- Drop triggers first
|
||||
DROP TRIGGER IF EXISTS refresh_crop_analytics_trigger_croplands ON public.croplands;
|
||||
DROP TRIGGER IF EXISTS refresh_crop_analytics_trigger_plants ON public.plants;
|
||||
|
||||
-- Drop the refresh function
|
||||
DROP FUNCTION IF EXISTS public.refresh_crop_analytics_view();
|
||||
|
||||
-- Drop the materialized view and its indexes
|
||||
DROP MATERIALIZED VIEW IF EXISTS public.crop_analytics_view; -- Indexes are dropped automatically
|
||||
120
backend/migrations/00013_modify_inventory_and_harvest_units.sql
Normal file
120
backend/migrations/00013_modify_inventory_and_harvest_units.sql
Normal file
@ -0,0 +1,120 @@
|
||||
-- +goose Up
|
||||
-- Step 1: Create inventory_category table
|
||||
CREATE TABLE inventory_category (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Step 2: Insert sample categories (add more as needed)
|
||||
INSERT INTO inventory_category (name)
|
||||
VALUES
|
||||
('Seeds'),
|
||||
('Fertilizers'),
|
||||
('Pesticides'),
|
||||
('Herbicides'),
|
||||
('Tools'),
|
||||
('Equipment'),
|
||||
('Fuel'),
|
||||
('Harvested Goods'), -- If harvested items go into inventory
|
||||
('Other');
|
||||
|
||||
-- Step 3: Add category_id column to inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN category_id INT;
|
||||
|
||||
-- Step 4: Update category_id based on old 'category' text (best effort)
|
||||
-- Map known old categories to new IDs. Set others to 'Other' or NULL.
|
||||
UPDATE inventory_items inv SET category_id = (SELECT id FROM inventory_category cat WHERE cat.name = inv.category)
|
||||
WHERE EXISTS (SELECT 1 FROM inventory_category cat WHERE cat.name = inv.category);
|
||||
-- Example: Set remaining to 'Other'
|
||||
-- UPDATE inventory_items SET category_id = (SELECT id FROM inventory_category WHERE name = 'Other') WHERE category_id IS NULL;
|
||||
|
||||
-- Step 5: Add foreign key constraint for category_id
|
||||
ALTER TABLE inventory_items
|
||||
ADD CONSTRAINT fk_inventory_category FOREIGN KEY (category_id) REFERENCES inventory_category(id) ON DELETE SET NULL; -- Or RESTRICT
|
||||
|
||||
-- Step 6: Add unit_id column to inventory_items (linking to harvest_units)
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN unit_id INT;
|
||||
|
||||
-- Step 7: Insert common inventory units into harvest_units table (if they don't exist)
|
||||
-- harvest_units was created in 00002, potentially with crop-specific units. Add general ones here.
|
||||
INSERT INTO harvest_units (name) VALUES
|
||||
('Piece(s)'),
|
||||
('Bag(s)'),
|
||||
('Box(es)'),
|
||||
('Liter(s)'),
|
||||
('Gallon(s)'),
|
||||
('kg'), -- Use consistent casing, e.g., lowercase
|
||||
('tonne'),
|
||||
('meter(s)'),
|
||||
('hour(s)')
|
||||
ON CONFLICT (name) DO NOTHING; -- Avoid errors if units already exist
|
||||
|
||||
-- Step 8: Update unit_id based on old 'unit' text (best effort)
|
||||
UPDATE inventory_items inv SET unit_id = (SELECT id FROM harvest_units hu WHERE hu.name = inv.unit)
|
||||
WHERE EXISTS (SELECT 1 FROM harvest_units hu WHERE hu.name = inv.unit);
|
||||
-- Handle cases where the old unit might not be in the harvest_units table (optional)
|
||||
-- UPDATE inventory_items SET unit_id = <default_unit_id> WHERE unit_id IS NULL;
|
||||
|
||||
-- Step 9: Add foreign key constraint for unit_id
|
||||
ALTER TABLE inventory_items
|
||||
ADD CONSTRAINT fk_inventory_unit FOREIGN KEY (unit_id) REFERENCES harvest_units(id) ON DELETE SET NULL; -- Or RESTRICT
|
||||
|
||||
-- Step 10: Remove old columns (type, category, unit) from inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
DROP COLUMN type,
|
||||
DROP COLUMN category,
|
||||
DROP COLUMN unit;
|
||||
|
||||
-- Step 11: Add indexes for new foreign keys
|
||||
CREATE INDEX idx_inventory_items_category_id ON inventory_items(category_id);
|
||||
CREATE INDEX idx_inventory_items_unit_id ON inventory_items(unit_id);
|
||||
|
||||
|
||||
-- +goose Down
|
||||
-- Reverse Step 11: Drop indexes
|
||||
DROP INDEX IF EXISTS idx_inventory_items_category_id;
|
||||
DROP INDEX IF EXISTS idx_inventory_items_unit_id;
|
||||
|
||||
-- Reverse Step 10: Add back type, category, and unit columns
|
||||
-- Mark as NOT NULL if they were originally required
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN type TEXT,
|
||||
ADD COLUMN category TEXT,
|
||||
ADD COLUMN unit TEXT;
|
||||
|
||||
-- Attempt to restore data (best effort, potential data loss)
|
||||
UPDATE inventory_items inv SET category = (SELECT name FROM inventory_category cat WHERE cat.id = inv.category_id)
|
||||
WHERE inv.category_id IS NOT NULL;
|
||||
UPDATE inventory_items inv SET unit = (SELECT name FROM harvest_units hu WHERE hu.id = inv.unit_id)
|
||||
WHERE inv.unit_id IS NOT NULL;
|
||||
-- Cannot restore 'type' as it was dropped without replacement.
|
||||
|
||||
-- Reverse Step 9: Remove the foreign key constraint for unit
|
||||
ALTER TABLE inventory_items
|
||||
DROP CONSTRAINT IF EXISTS fk_inventory_unit;
|
||||
|
||||
-- Reverse Step 8: (Data restoration attempted above)
|
||||
|
||||
-- Reverse Step 7: (Cannot easily remove only units added here without knowing originals)
|
||||
-- DELETE FROM harvest_units WHERE name IN ('Piece(s)', 'Bag(s)', ...); -- Risky if names overlapped
|
||||
|
||||
-- Reverse Step 6: Remove unit_id column from inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
DROP COLUMN unit_id;
|
||||
|
||||
-- Reverse Step 5: Remove foreign key constraint for category
|
||||
ALTER TABLE inventory_items
|
||||
DROP CONSTRAINT IF EXISTS fk_inventory_category;
|
||||
|
||||
-- Reverse Step 4: (Data restoration attempted above)
|
||||
|
||||
-- Reverse Step 3: Remove category_id column from inventory_items
|
||||
ALTER TABLE inventory_items
|
||||
DROP COLUMN category_id;
|
||||
|
||||
-- Reverse Step 2: (Data in inventory_category table is kept unless explicitly dropped)
|
||||
|
||||
-- Reverse Step 1: Drop inventory_category table
|
||||
DROP TABLE IF EXISTS inventory_category;
|
||||
159
backend/migrations/00014_create_farm_analytics_table.sql
Normal file
159
backend/migrations/00014_create_farm_analytics_table.sql
Normal file
@ -0,0 +1,159 @@
|
||||
-- +goose Up
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view CASCADE;
|
||||
DROP FUNCTION IF EXISTS public.refresh_farm_analytics_view() CASCADE;
|
||||
DROP MATERIALIZED VIEW IF EXISTS public.crop_analytics_view CASCADE;
|
||||
DROP FUNCTION IF EXISTS public.refresh_crop_analytics_view() CASCADE;
|
||||
|
||||
CREATE TABLE public.farm_analytics (
|
||||
farm_id UUID PRIMARY KEY NOT NULL,
|
||||
farm_name TEXT NOT NULL,
|
||||
owner_id UUID NOT NULL,
|
||||
farm_type TEXT,
|
||||
total_size TEXT,
|
||||
latitude DOUBLE PRECISION NOT NULL,
|
||||
longitude DOUBLE PRECISION NOT NULL,
|
||||
|
||||
weather_temp_celsius DOUBLE PRECISION,
|
||||
weather_humidity DOUBLE PRECISION,
|
||||
weather_description TEXT,
|
||||
weather_icon TEXT,
|
||||
weather_wind_speed DOUBLE PRECISION,
|
||||
weather_rain_1h DOUBLE PRECISION,
|
||||
weather_observed_at TIMESTAMPTZ, -- Timestamp from the weather data itself
|
||||
weather_last_updated TIMESTAMPTZ, -- Timestamp when weather was last fetched/updated in this record
|
||||
|
||||
inventory_total_items INT DEFAULT 0 NOT NULL,
|
||||
inventory_low_stock_count INT DEFAULT 0 NOT NULL,
|
||||
inventory_last_updated TIMESTAMPTZ,
|
||||
|
||||
crop_total_count INT DEFAULT 0 NOT NULL,
|
||||
crop_growing_count INT DEFAULT 0 NOT NULL, -- Example: specific status count
|
||||
crop_last_updated TIMESTAMPTZ,
|
||||
|
||||
overall_status TEXT, -- e.g., 'ok', 'warning', 'critical' - Can be updated by various events
|
||||
|
||||
analytics_last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When this specific analytics record was last touched
|
||||
|
||||
CONSTRAINT fk_farm_analytics_farm FOREIGN KEY (farm_id) REFERENCES public.farms(uuid) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_farm_analytics_owner FOREIGN KEY (owner_id) REFERENCES public.users(uuid) ON DELETE CASCADE -- Assuming owner_id refers to users.uuid
|
||||
);
|
||||
|
||||
CREATE INDEX idx_farm_analytics_owner_id ON public.farm_analytics(owner_id);
|
||||
CREATE INDEX idx_farm_analytics_last_updated ON public.farm_analytics(analytics_last_updated DESC);
|
||||
CREATE INDEX idx_farm_analytics_weather_last_updated ON public.farm_analytics(weather_last_updated DESC);
|
||||
|
||||
-- Optional: Initial data population (run once after table creation if needed)
|
||||
-- INSERT INTO public.farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude, analytics_last_updated)
|
||||
-- SELECT uuid, name, owner_id, farm_type, total_size, lat, lon, updated_at
|
||||
-- FROM public.farms
|
||||
-- ON CONFLICT (farm_id) DO NOTHING;
|
||||
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP TABLE IF EXISTS public.farm_analytics;
|
||||
|
||||
CREATE MATERIALIZED VIEW public.farm_analytics_view AS
|
||||
SELECT
|
||||
f.uuid AS farm_id,
|
||||
f.name AS farm_name,
|
||||
f.owner_id,
|
||||
f.farm_type,
|
||||
f.total_size,
|
||||
COALESCE(
|
||||
(SELECT MAX(ae_max.created_at) FROM public.analytics_events ae_max WHERE ae_max.farm_id = f.uuid),
|
||||
f.updated_at
|
||||
) AS last_updated,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'last_updated', latest_weather.created_at,
|
||||
'temperature', (latest_weather.event_data->>'temperature')::float,
|
||||
'humidity', (latest_weather.event_data->>'humidity')::float,
|
||||
'rainfall', (latest_weather.event_data->>'rainfall')::float,
|
||||
'wind_speed', (latest_weather.event_data->>'wind_speed')::float,
|
||||
'weather_status', latest_weather.event_data->>'weather_status',
|
||||
'alert_level', latest_weather.event_data->>'alert_level',
|
||||
'forecast_summary', latest_weather.event_data->>'forecast_summary'
|
||||
)
|
||||
FROM (
|
||||
SELECT ae_w.event_data, ae_w.created_at
|
||||
FROM public.analytics_events ae_w
|
||||
WHERE ae_w.farm_id = f.uuid AND ae_w.event_type = 'weather.updated'
|
||||
ORDER BY ae_w.created_at DESC
|
||||
LIMIT 1
|
||||
) AS latest_weather
|
||||
) AS weather_data,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'items', COALESCE(jsonb_agg(ae_i.event_data->'items' ORDER BY (ae_i.event_data->>'timestamp') DESC) FILTER (WHERE ae_i.event_data ? 'items'), '[]'::jsonb),
|
||||
'last_updated', MAX(ae_i.created_at)
|
||||
)
|
||||
FROM analytics_events ae_i
|
||||
WHERE ae_i.farm_id = f.uuid AND ae_i.event_type = 'inventory.updated'
|
||||
) AS inventory_data,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'status', MAX(ae_p.event_data->>'status'),
|
||||
'issues', COALESCE(jsonb_agg(ae_p.event_data->'issues') FILTER (WHERE ae_p.event_data ? 'issues'), '[]'::jsonb),
|
||||
'last_updated', MAX(ae_p.created_at)
|
||||
)
|
||||
FROM analytics_events ae_p
|
||||
WHERE ae_p.farm_id = f.uuid AND ae_p.event_type = 'plant_health.updated'
|
||||
) AS plant_health_data,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'yield_total', SUM((ae_pr.event_data->>'yield')::float) FILTER (WHERE ae_pr.event_data ? 'yield'),
|
||||
'forecast_latest', MAX(ae_pr.event_data->>'forecast') FILTER (WHERE ae_pr.event_data ? 'forecast'),
|
||||
'last_updated', MAX(ae_pr.created_at)
|
||||
)
|
||||
FROM analytics_events ae_pr
|
||||
WHERE ae_pr.farm_id = f.uuid AND ae_pr.event_type = 'production.updated'
|
||||
) AS production_data
|
||||
FROM
|
||||
public.farms f;
|
||||
|
||||
CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id);
|
||||
CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id);
|
||||
|
||||
-- +goose StatementBegin
|
||||
CREATE OR REPLACE FUNCTION public.refresh_farm_analytics_view()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY public.farm_analytics_view;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
-- +goose StatementEnd
|
||||
|
||||
CREATE TRIGGER refresh_farm_analytics_view_trigger_events
|
||||
AFTER INSERT ON public.analytics_events -- Adjust if original trigger was different
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION public.refresh_farm_analytics_view();
|
||||
|
||||
CREATE TRIGGER refresh_farm_analytics_view_trigger_farms
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.farms -- Adjust if original trigger was different
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION public.refresh_farm_analytics_view();
|
||||
|
||||
CREATE MATERIALIZED VIEW public.crop_analytics_view AS
|
||||
SELECT
|
||||
c.uuid AS crop_id, c.name AS crop_name, c.farm_id, p.name AS plant_name, p.variety AS variety,
|
||||
c.status AS current_status, c.growth_stage, c.land_size, c.geo_feature, c.updated_at AS last_updated
|
||||
FROM public.croplands c JOIN public.plants p ON c.plant_id = p.uuid;
|
||||
CREATE UNIQUE INDEX idx_crop_analytics_view_crop_id ON public.crop_analytics_view(crop_id);
|
||||
CREATE INDEX idx_crop_analytics_view_farm_id ON public.crop_analytics_view(farm_id);
|
||||
CREATE INDEX idx_crop_analytics_view_plant_name ON public.crop_analytics_view(plant_name);
|
||||
-- +goose StatementBegin
|
||||
CREATE OR REPLACE FUNCTION public.refresh_crop_analytics_view()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY public.crop_analytics_view;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
-- +goose StatementEnd
|
||||
CREATE TRIGGER refresh_crop_analytics_trigger_croplands
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.croplands FOR EACH STATEMENT EXECUTE FUNCTION public.refresh_crop_analytics_view();
|
||||
CREATE TRIGGER refresh_crop_analytics_trigger_plants
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.plants FOR EACH STATEMENT EXECUTE FUNCTION public.refresh_crop_analytics_view();
|
||||
@ -6,3 +6,5 @@ import (
|
||||
|
||||
//go:embed *.sql
|
||||
var EmbedMigrations embed.FS
|
||||
|
||||
const MigrationsDir = "migrations"
|
||||
|
||||
54
frontend/api/crop.ts
Normal file
54
frontend/api/crop.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import axiosInstance from "./config";
|
||||
// Use refactored types
|
||||
import type { Cropland, CropAnalytics } from "@/types";
|
||||
|
||||
export interface CropResponse {
|
||||
croplands: Cropland[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all Croplands for a specific FarmID. Returns CropResponse.
|
||||
*/
|
||||
export async function getCropsByFarmId(farmId: string): Promise<CropResponse> {
|
||||
// Assuming backend returns { "croplands": [...] }
|
||||
return axiosInstance.get<CropResponse>(`/crop/farm/${farmId}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific Cropland by its ID. Returns Cropland.
|
||||
*/
|
||||
export async function getCropById(cropId: string): Promise<Cropland> {
|
||||
// Assuming backend returns { "cropland": ... }
|
||||
return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
|
||||
// If backend returns object directly: return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new crop (Cropland). Sends camelCase data matching backend tags. Returns Cropland.
|
||||
*/
|
||||
export async function createCrop(data: Partial<Omit<Cropland, "uuid" | "createdAt" | "updatedAt">>): Promise<Cropland> {
|
||||
if (!data.farmId) {
|
||||
throw new Error("farmId is required to create a crop.");
|
||||
}
|
||||
// Payload uses camelCase keys matching backend JSON tags
|
||||
const payload = {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
landSize: data.landSize,
|
||||
growthStage: data.growthStage,
|
||||
plantId: data.plantId,
|
||||
farmId: data.farmId,
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch analytics data for a specific crop by its ID. Returns CropAnalytics.
|
||||
*/
|
||||
export async function fetchCropAnalytics(cropId: string): Promise<CropAnalytics> {
|
||||
// Assuming backend returns { body: { ... } } structure from Huma
|
||||
return axiosInstance.get<CropAnalytics>(`/analytics/crop/${cropId}`).then((res) => res.data);
|
||||
}
|
||||
@ -1,198 +1,63 @@
|
||||
import axiosInstance from "./config";
|
||||
import type { Crop, CropAnalytics, Farm } from "@/types";
|
||||
// Use the refactored Farm type
|
||||
import type { Farm } from "@/types";
|
||||
|
||||
/**
|
||||
* Fetch a specific crop by id using axios.
|
||||
* Falls back to dummy data on error.
|
||||
*/
|
||||
export async function fetchCropById(id: string): Promise<Crop> {
|
||||
try {
|
||||
const response = await axiosInstance.get<Crop>(`/api/crops/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Fallback dummy data
|
||||
return {
|
||||
id,
|
||||
farmId: "1",
|
||||
name: "Monthong Durian",
|
||||
plantedDate: new Date("2024-01-15"),
|
||||
status: "growing",
|
||||
variety: "Premium Grade",
|
||||
expectedHarvest: new Date("2024-07-15"),
|
||||
area: "2.5 hectares",
|
||||
healthScore: 85,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch crop analytics by crop id using axios.
|
||||
* Returns dummy analytics if the API call fails.
|
||||
*/
|
||||
export async function fetchAnalyticsByCropId(id: string): Promise<CropAnalytics> {
|
||||
try {
|
||||
const response = await axiosInstance.get<CropAnalytics>(`/api/crops/${id}/analytics`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return {
|
||||
cropId: id,
|
||||
growthProgress: 45,
|
||||
humidity: 75,
|
||||
temperature: 28,
|
||||
sunlight: 85,
|
||||
waterLevel: 65,
|
||||
plantHealth: "good",
|
||||
nextAction: "Water the plant",
|
||||
nextActionDue: new Date("2024-02-15"),
|
||||
soilMoisture: 70,
|
||||
windSpeed: "12 km/h",
|
||||
rainfall: "25mm last week",
|
||||
nutrientLevels: {
|
||||
nitrogen: 80,
|
||||
phosphorus: 65,
|
||||
potassium: 75,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an array of farms using axios.
|
||||
* Simulates a delay and a random error; returns dummy data if the API is unavailable.
|
||||
* Fetch an array of farms. Returns Farm[].
|
||||
*/
|
||||
export async function fetchFarms(): Promise<Farm[]> {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.get<Farm[]>("/api/farms");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Optionally, you could simulate a random error here. For now we return fallback data.
|
||||
return [
|
||||
{
|
||||
id: "1",
|
||||
name: "Green Valley Farm",
|
||||
location: "Bangkok",
|
||||
type: "durian",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
area: "12.5 hectares",
|
||||
crops: 5,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Sunrise Orchard",
|
||||
location: "Chiang Mai",
|
||||
type: "mango",
|
||||
createdAt: new Date("2023-02-15"),
|
||||
area: "8.3 hectares",
|
||||
crops: 3,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Golden Harvest Fields",
|
||||
location: "Phuket",
|
||||
type: "rice",
|
||||
createdAt: new Date("2023-03-22"),
|
||||
area: "20.1 hectares",
|
||||
crops: 2,
|
||||
},
|
||||
];
|
||||
}
|
||||
// Backend already returns camelCase due to updated JSON tags
|
||||
return axiosInstance.get<Farm[]>("/farms").then((res) => res.data); // Assuming backend wraps in { "farms": [...] }
|
||||
// If backend returns array directly: return axiosInstance.get<Farm[]>("/farms").then((res) => res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates creating a new farm.
|
||||
* Waits for 800ms and then uses dummy data.
|
||||
* Create a new farm. Sends camelCase data. Returns Farm.
|
||||
*/
|
||||
export async function createFarm(data: Partial<Farm>): Promise<Farm> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
// In a real implementation you might call:
|
||||
// const response = await axiosInstance.post<Farm>("/api/farms", data);
|
||||
// return response.data;
|
||||
return {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: data.name!,
|
||||
location: data.location!,
|
||||
type: data.type!,
|
||||
createdAt: new Date(),
|
||||
area: data.area || "0 hectares",
|
||||
crops: 0,
|
||||
export async function createFarm(
|
||||
data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>
|
||||
): Promise<Farm> {
|
||||
// Construct payload matching backend expected camelCase tags
|
||||
const payload = {
|
||||
name: data.name,
|
||||
lat: data.lat,
|
||||
lon: data.lon,
|
||||
farmType: data.farmType,
|
||||
totalSize: data.totalSize,
|
||||
// ownerId is added by backend based on token
|
||||
};
|
||||
return axiosInstance.post<Farm>("/farms", payload).then((res) => res.data);
|
||||
}
|
||||
|
||||
// Additional functions for fetching crop details remain unchanged...
|
||||
|
||||
/**
|
||||
* Fetch detailed information for a specific farm (including its crops) using axios.
|
||||
* If the API call fails, returns fallback dummy data.
|
||||
* Fetch a specific farm by ID. Returns Farm.
|
||||
*/
|
||||
export async function fetchFarmDetails(farmId: string): Promise<{ farm: Farm; crops: Crop[] }> {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.get<{ farm: Farm; crops: Crop[] }>(`/api/farms/${farmId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// If the given farmId is "999", simulate a not found error.
|
||||
if (farmId === "999") {
|
||||
throw new Error("FARM_NOT_FOUND");
|
||||
}
|
||||
|
||||
const farm: Farm = {
|
||||
id: farmId,
|
||||
name: "Green Valley Farm",
|
||||
location: "Bangkok, Thailand",
|
||||
type: "durian",
|
||||
createdAt: new Date("2023-01-15"),
|
||||
area: "12.5 hectares",
|
||||
crops: 3,
|
||||
// Additional details such as weather can be included if needed.
|
||||
weather: {
|
||||
temperature: 28,
|
||||
humidity: 75,
|
||||
rainfall: "25mm last week",
|
||||
sunlight: 85,
|
||||
},
|
||||
};
|
||||
|
||||
const crops: Crop[] = [
|
||||
{
|
||||
id: "1",
|
||||
farmId,
|
||||
name: "Monthong Durian",
|
||||
plantedDate: new Date("2023-03-15"),
|
||||
status: "growing",
|
||||
variety: "Premium",
|
||||
area: "4.2 hectares",
|
||||
healthScore: 92,
|
||||
progress: 65,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
farmId,
|
||||
name: "Chanee Durian",
|
||||
plantedDate: new Date("2023-02-20"),
|
||||
status: "planned",
|
||||
variety: "Standard",
|
||||
area: "3.8 hectares",
|
||||
healthScore: 0,
|
||||
progress: 0,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
farmId,
|
||||
name: "Kradum Durian",
|
||||
plantedDate: new Date("2022-11-05"),
|
||||
status: "harvested",
|
||||
variety: "Premium",
|
||||
area: "4.5 hectares",
|
||||
healthScore: 100,
|
||||
progress: 100,
|
||||
},
|
||||
];
|
||||
|
||||
return { farm, crops };
|
||||
}
|
||||
export async function getFarm(farmId: string): Promise<Farm> {
|
||||
return axiosInstance.get<Farm>(`/farms/${farmId}`).then((res) => res.data); // Assuming backend wraps in { "farm": ... }
|
||||
// If backend returns object directly: return axiosInstance.get<Farm>(`/farms/${farmId}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing farm. Sends camelCase data. Returns Farm.
|
||||
*/
|
||||
export async function updateFarm(
|
||||
farmId: string,
|
||||
data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>
|
||||
): Promise<Farm> {
|
||||
// Construct payload matching backend expected camelCase tags
|
||||
const payload = {
|
||||
name: data.name,
|
||||
lat: data.lat,
|
||||
lon: data.lon,
|
||||
farmType: data.farmType,
|
||||
totalSize: data.totalSize,
|
||||
};
|
||||
return axiosInstance.put<Farm>(`/farms/${farmId}`, payload).then((res) => res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific farm. Returns { message: string }.
|
||||
*/
|
||||
export async function deleteFarm(farmId: string): Promise<{ message: string }> {
|
||||
return axiosInstance.delete(`/farms/${farmId}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
12
frontend/api/harvest.ts
Normal file
12
frontend/api/harvest.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import axiosInstance from "./config";
|
||||
import type { HarvestUnits } from "@/types";
|
||||
|
||||
export async function fetchHarvestUnits(): Promise<HarvestUnits[]> {
|
||||
try {
|
||||
const response = await axiosInstance.get<HarvestUnits[]>("/harvest/units");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching inventory status:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,23 @@
|
||||
import axiosInstance from "./config";
|
||||
import type {
|
||||
InventoryItem,
|
||||
InventoryStatus,
|
||||
InventoryItemCategory,
|
||||
CreateInventoryItemInput,
|
||||
InventoryItemStatus,
|
||||
EditInventoryItemInput,
|
||||
} from "@/types";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
/**
|
||||
* Simulates an API call to fetch inventory items.
|
||||
* Waits for a simulated delay and then attempts an axios GET request.
|
||||
* If the request fails, returns fallback dummy data.
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*/
|
||||
export async function fetchInventoryStatus(): Promise<InventoryItemStatus[]> {
|
||||
export async function fetchInventoryStatus(): Promise<InventoryStatus[]> {
|
||||
try {
|
||||
const response = await axiosInstance.get<InventoryItemStatus[]>(
|
||||
const response = await axiosInstance.get<InventoryStatus[]>(
|
||||
"/inventory/status"
|
||||
);
|
||||
return response.data;
|
||||
@ -23,96 +26,136 @@ export async function fetchInventoryStatus(): Promise<InventoryItemStatus[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export async function fetchInventoryCategory(): Promise<
|
||||
InventoryItemCategory[]
|
||||
> {
|
||||
try {
|
||||
const response = await axiosInstance.get<InventoryItemCategory[]>(
|
||||
"/inventory/category"
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching inventory status:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchInventoryItems(): Promise<InventoryItem[]> {
|
||||
try {
|
||||
const response = await axiosInstance.get<InventoryItem[]>("/api/inventory");
|
||||
const response = await axiosInstance.get<InventoryItem[]>("/inventory");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Fallback dummy data
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
name: "Tomato Seeds",
|
||||
category: "Seeds",
|
||||
type: "Plantation",
|
||||
quantity: 500,
|
||||
unit: "packets",
|
||||
lastUpdated: "2023-03-01",
|
||||
status: "In Stock",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "NPK Fertilizer",
|
||||
category: "Fertilizer",
|
||||
type: "Fertilizer",
|
||||
quantity: 200,
|
||||
unit: "kg",
|
||||
lastUpdated: "2023-03-05",
|
||||
status: "Low Stock",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Corn Seeds",
|
||||
category: "Seeds",
|
||||
type: "Plantation",
|
||||
quantity: 300,
|
||||
unit: "packets",
|
||||
lastUpdated: "2023-03-10",
|
||||
status: "In Stock",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Organic Compost",
|
||||
category: "Fertilizer",
|
||||
type: "Fertilizer",
|
||||
quantity: 150,
|
||||
unit: "kg",
|
||||
lastUpdated: "2023-03-15",
|
||||
status: "Out Of Stock",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Wheat Seeds",
|
||||
category: "Seeds",
|
||||
type: "Plantation",
|
||||
quantity: 250,
|
||||
unit: "packets",
|
||||
lastUpdated: "2023-03-20",
|
||||
status: "In Stock",
|
||||
},
|
||||
];
|
||||
console.error("Error while fetching inventory items! " + error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates creating a new inventory item.
|
||||
* Uses axios POST and if unavailable, returns a simulated response.
|
||||
*
|
||||
* Note: The function accepts all fields except id, lastUpdated, and status.
|
||||
*/
|
||||
export async function createInventoryItem(
|
||||
item: Omit<InventoryItem, "id" | "lastUpdated" | "status">
|
||||
item: Omit<CreateInventoryItemInput, "id" | "lastUpdated" | "status">
|
||||
): Promise<InventoryItem> {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
const response = await axiosInstance.post<InventoryItem>(
|
||||
"/api/inventory",
|
||||
"/inventory",
|
||||
item
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Simulate successful creation if API endpoint is not available
|
||||
return {
|
||||
id: Math.floor(Math.random() * 1000),
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
type: item.type,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
status: "In Stock",
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Cast error to AxiosError to safely access response properties
|
||||
if (error instanceof AxiosError && error.response) {
|
||||
// Log the detailed error message
|
||||
console.error("Error while creating Inventory Item!");
|
||||
console.error("Response Status:", error.response.status); // e.g., 422
|
||||
console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend
|
||||
console.error("Full Error Response:", error.response.data); // Entire error object (including details)
|
||||
|
||||
// Throw a new error with a more specific message
|
||||
throw new Error(
|
||||
`Failed to create inventory item: ${
|
||||
error.response.data?.detail || error.message
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
// Handle other errors (e.g., network errors or unknown errors)
|
||||
console.error(
|
||||
"Error while creating Inventory Item, unknown error:",
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
"Failed to create inventory item: " +
|
||||
(error instanceof Error ? error.message : "Unknown error")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteInventoryItem(id: string) {
|
||||
try {
|
||||
const response = await axiosInstance.delete("/inventory/" + id);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
// Cast error to AxiosError to safely access response properties
|
||||
if (error instanceof AxiosError && error.response) {
|
||||
// Log the detailed error message
|
||||
console.error("Error while deleting Inventory Item!");
|
||||
console.error("Response Status:", error.response.status); // e.g., 422
|
||||
console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend
|
||||
console.error("Full Error Response:", error.response.data); // Entire error object (including details)
|
||||
|
||||
// Throw a new error with a more specific message
|
||||
throw new Error(
|
||||
`Failed to delete inventory item: ${
|
||||
error.response.data?.detail || error.message
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
// Handle other errors (e.g., network errors or unknown errors)
|
||||
console.error(
|
||||
"Error while deleting Inventory Item, unknown error:",
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
"Failed to delete inventory item: " +
|
||||
(error instanceof Error ? error.message : "Unknown error")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function updateInventoryItem(
|
||||
id: string,
|
||||
item: EditInventoryItemInput
|
||||
) {
|
||||
// console.log(id);
|
||||
try {
|
||||
const response = await axiosInstance.put<InventoryItem>(
|
||||
`/inventory/${id}`,
|
||||
item
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
// Cast error to AxiosError to safely access response properties
|
||||
if (error instanceof AxiosError && error.response) {
|
||||
// Log the detailed error message
|
||||
console.error("Error while deleting Inventory Item!");
|
||||
console.error("Response Status:", error.response.status); // e.g., 422
|
||||
console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend
|
||||
console.error("Full Error Response:", error.response.data); // Entire error object (including details)
|
||||
|
||||
// Throw a new error with a more specific message
|
||||
throw new Error(
|
||||
`Failed to delete inventory item: ${
|
||||
error.response.data?.detail || error.message
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
// Handle other errors (e.g., network errors or unknown errors)
|
||||
console.error(
|
||||
"Error while deleting Inventory Item, unknown error:",
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
"Failed to delete inventory item: " +
|
||||
(error instanceof Error ? error.message : "Unknown error")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
frontend/api/plant.ts
Normal file
10
frontend/api/plant.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import axiosInstance from "./config";
|
||||
import type { Plant } from "@/types";
|
||||
|
||||
export interface PlantResponse {
|
||||
plants: Plant[];
|
||||
}
|
||||
|
||||
export function getPlants(): Promise<PlantResponse> {
|
||||
return axiosInstance.get<PlantResponse>("/plant").then((res) => res.data);
|
||||
}
|
||||
@ -7,62 +7,131 @@ 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, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Crop } from "@/types";
|
||||
import { cropFormSchema } from "@/schemas/form.schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getPlants, PlantResponse } from "@/api/plant";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Cropland } from "@/types";
|
||||
|
||||
// Update schema to reflect Cropland fields needed for creation
|
||||
// Removed plantedDate as it's derived from createdAt on backend
|
||||
// Added plantId, landSize, growthStage, priority
|
||||
const cropFormSchema = z.object({
|
||||
name: z.string().min(2, "Crop name must be at least 2 characters"),
|
||||
plantId: z.string().uuid("Please select a valid plant"), // Changed from name to ID
|
||||
status: z.enum(["planned", "growing", "harvested", "fallow"]), // Added fallow
|
||||
landSize: z.preprocess(
|
||||
(val) => parseFloat(z.string().parse(val)), // Convert string input to number
|
||||
z.number().positive("Land size must be a positive number")
|
||||
),
|
||||
growthStage: z.string().min(1, "Growth stage is required"),
|
||||
priority: z.preprocess(
|
||||
(val) => parseInt(z.string().parse(val), 10), // Convert string input to number
|
||||
z.number().int().min(0, "Priority must be non-negative")
|
||||
),
|
||||
// GeoFeature will be handled separately by the map component later
|
||||
});
|
||||
|
||||
interface AddCropFormProps {
|
||||
onSubmit: (data: Partial<Crop>) => Promise<void>;
|
||||
onSubmit: (data: Partial<Cropland>) => Promise<void>; // Expect Partial<Cropland>
|
||||
onCancel: () => void;
|
||||
isSubmitting: boolean; // Receive submitting state
|
||||
}
|
||||
|
||||
export function AddCropForm({ onSubmit, onCancel }: AddCropFormProps) {
|
||||
export function AddCropForm({ onSubmit, onCancel, isSubmitting }: AddCropFormProps) {
|
||||
// Fetch plants for the dropdown
|
||||
const {
|
||||
data: plantData,
|
||||
isLoading: isLoadingPlants,
|
||||
isError: isErrorPlants,
|
||||
} = useQuery<PlantResponse>({
|
||||
queryKey: ["plants"],
|
||||
queryFn: getPlants,
|
||||
staleTime: 1000 * 60 * 60, // Cache for 1 hour
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof cropFormSchema>>({
|
||||
resolver: zodResolver(cropFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
plantedDate: "",
|
||||
plantId: "", // Initialize plantId
|
||||
status: "planned",
|
||||
landSize: 0,
|
||||
growthStage: "Planned",
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: z.infer<typeof cropFormSchema>) => {
|
||||
// Submit data shaped like Partial<Cropland>
|
||||
onSubmit({
|
||||
...values,
|
||||
plantedDate: new Date(values.plantedDate),
|
||||
name: values.name,
|
||||
plantId: values.plantId,
|
||||
status: values.status,
|
||||
landSize: values.landSize,
|
||||
growthStage: values.growthStage,
|
||||
priority: values.priority,
|
||||
// farmId is added in the parent component's mutationFn
|
||||
// geoFeature would be passed separately if using map here
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Crop Name</FormLabel>
|
||||
<FormLabel>Cropland Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter crop name" {...field} />
|
||||
<Input placeholder="e.g., North Field Tomatoes" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Plant Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="plantedDate"
|
||||
name="plantId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Planted Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormLabel>Select Plant</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
disabled={isLoadingPlants || isErrorPlants}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingPlants
|
||||
? "Loading plants..."
|
||||
: isErrorPlants
|
||||
? "Error loading plants"
|
||||
: "Select the main plant for this cropland"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{!isLoadingPlants &&
|
||||
!isErrorPlants &&
|
||||
plantData?.plants.map((plant) => (
|
||||
<SelectItem key={plant.uuid} value={plant.uuid}>
|
||||
{plant.name} {plant.variety ? `(${plant.variety})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Status Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
@ -79,6 +148,7 @@ export function AddCropForm({ onSubmit, onCancel }: AddCropFormProps) {
|
||||
<SelectItem value="planned">Planned</SelectItem>
|
||||
<SelectItem value="growing">Growing</SelectItem>
|
||||
<SelectItem value="harvested">Harvested</SelectItem>
|
||||
<SelectItem value="fallow">Fallow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@ -86,11 +156,73 @@ export function AddCropForm({ onSubmit, onCancel }: AddCropFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{/* Land Size */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="landSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Land Size (e.g., Hectares)</FormLabel>
|
||||
<FormControl>
|
||||
{/* Use text input for flexibility, validation handles number conversion */}
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g., 1.5"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Growth Stage */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="growthStage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Initial Growth Stage</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., Seedling, Vegetative" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Priority */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g., 1"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* TODO: Add GeoFeature input using the map component if needed within this dialog */}
|
||||
{/* <div className="h-64 border rounded-md overflow-hidden"> <GoogleMapWithDrawing onShapeDrawn={...} /> </div> */}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add Crop</Button>
|
||||
<Button type="submit" disabled={isSubmitting || isLoadingPlants}>
|
||||
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{isSubmitting ? "Adding Crop..." : "Add Crop"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -1,19 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card";
|
||||
import { Sprout, Calendar, ArrowRight, BarChart } from "lucide-react";
|
||||
import { Calendar, ArrowRight, Layers } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { Crop } from "@/types";
|
||||
import type { Cropland } from "@/types";
|
||||
|
||||
// ===================================================================
|
||||
// Component Props: CropCard expects a cropland object and an optional click handler.
|
||||
// ===================================================================
|
||||
interface CropCardProps {
|
||||
crop: Crop;
|
||||
crop: Cropland; // Crop data conforming to the Cropland type
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Component: CropCard
|
||||
// - Displays summary information about a crop, including status,
|
||||
// created date, and growth stage using an expressive card UI.
|
||||
// ===================================================================
|
||||
export function CropCard({ crop, onClick }: CropCardProps) {
|
||||
const statusColors = {
|
||||
// ---------------------------------------------------------------
|
||||
// Status color mapping: Determines badge styling based on crop status.
|
||||
// Default colors provided for unknown statuses.
|
||||
// ---------------------------------------------------------------
|
||||
const statusColors: Record<string, { bg: string; text: string; border: string }> = {
|
||||
growing: {
|
||||
bg: "bg-green-50 dark:bg-green-900",
|
||||
text: "text-green-600 dark:text-green-300",
|
||||
@ -29,67 +40,73 @@ export function CropCard({ crop, onClick }: CropCardProps) {
|
||||
text: "text-blue-600 dark:text-blue-300",
|
||||
border: "border-blue-200",
|
||||
},
|
||||
fallow: {
|
||||
bg: "bg-gray-50 dark:bg-gray-900",
|
||||
text: "text-gray-600 dark:text-gray-400",
|
||||
border: "border-gray-200",
|
||||
},
|
||||
default: {
|
||||
bg: "bg-gray-100 dark:bg-gray-700",
|
||||
text: "text-gray-800 dark:text-gray-200",
|
||||
border: "border-gray-300",
|
||||
},
|
||||
};
|
||||
|
||||
const statusColor = statusColors[crop.status as keyof typeof statusColors];
|
||||
// ---------------------------------------------------------------
|
||||
// Derive styling based on crop status (ignoring case).
|
||||
// ---------------------------------------------------------------
|
||||
const statusKey = crop.status?.toLowerCase() || "default"; // Use camelCase status
|
||||
const statusColor = statusColors[statusKey] || statusColors.default;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Format metadata for display: creation date and area.
|
||||
// ---------------------------------------------------------------
|
||||
const displayDate = crop.createdAt ? new Date(crop.createdAt).toLocaleDateString() : "N/A"; // Use camelCase createdAt
|
||||
const displayArea = typeof crop.landSize === "number" ? `${crop.landSize} ha` : "N/A"; // Use camelCase landSize
|
||||
|
||||
// ===================================================================
|
||||
// Render: Crop information card with clickable behavior.
|
||||
// ===================================================================
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className={`w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border-muted/60 bg-white dark:bg-slate-800 hover:bg-muted/10 dark:hover:bg-slate-700`}>
|
||||
className={`w-full h-full flex flex-col overflow-hidden transition-all duration-200 hover:shadow-lg border-muted/60 bg-card dark:bg-card hover:bg-muted/10 dark:hover:bg-slate-700 cursor-pointer`}>
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline" className={`capitalize ${statusColor.bg} ${statusColor.text} ${statusColor.border}`}>
|
||||
{crop.status}
|
||||
{crop.status || "Unknown"}
|
||||
</Badge>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
{crop.plantedDate.toLocaleDateString()}
|
||||
{displayDate}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-4 flex-grow">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`h-10 w-10 rounded-full ${statusColor.bg} flex-shrink-0 flex items-center justify-center`}>
|
||||
<Sprout className={`h-5 w-5 ${statusColor.text}`} />
|
||||
</div>
|
||||
{/* ... icon ... */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-medium mb-1">{crop.name}</h3>
|
||||
<h3 className="text-lg font-semibold mb-1 line-clamp-1">{crop.name}</h3> {/* Use camelCase name */}
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{crop.variety} • {crop.area}
|
||||
{crop.growthStage || "N/A"} • {displayArea} {/* Use camelCase growthStage */}
|
||||
</p>
|
||||
|
||||
{crop.status !== "planned" && (
|
||||
<div className="space-y-2 mt-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-medium">{crop.progress}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={crop.progress}
|
||||
className={`h-2 ${
|
||||
crop.status === "growing" ? "bg-green-500" : crop.status === "harvested" ? "bg-yellow-500" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crop.status === "growing" && (
|
||||
{crop.growthStage && (
|
||||
<div className="flex items-center mt-3 text-sm">
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-300">
|
||||
<BarChart className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Health: {crop.healthScore}%</span>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
{/* Use camelCase growthStage */}
|
||||
<span className="font-medium">Stage: {crop.growthStage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
<CardFooter className="p-4 pt-0 mt-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto gap-1 text-green-600 dark:text-green-300 hover:text-green-700 dark:hover:text-green-400 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">
|
||||
View details <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
||||
@ -1,125 +1,301 @@
|
||||
// crop-dialog.tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMapsLibrary } from "@vis.gl/react-google-maps";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Check, MapPin } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Check,
|
||||
Sprout,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
CalendarDays,
|
||||
Thermometer,
|
||||
Droplets,
|
||||
MapPin,
|
||||
Maximize,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Crop } from "@/types";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
||||
|
||||
interface Plant {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
growthTime: string;
|
||||
}
|
||||
|
||||
const plants: Plant[] = [
|
||||
{
|
||||
id: "durian",
|
||||
name: "Durian",
|
||||
image: "/placeholder.svg?height=80&width=80",
|
||||
growthTime: "4-5 months",
|
||||
},
|
||||
{
|
||||
id: "mango",
|
||||
name: "Mango",
|
||||
image: "/placeholder.svg?height=80&width=80",
|
||||
growthTime: "3-4 months",
|
||||
},
|
||||
{
|
||||
id: "coconut",
|
||||
name: "Coconut",
|
||||
image: "/placeholder.svg?height=80&width=80",
|
||||
growthTime: "5-6 months",
|
||||
},
|
||||
];
|
||||
// Import the updated/new types
|
||||
import type { Cropland, GeoFeatureData, GeoPosition } from "@/types";
|
||||
import { PlantResponse } from "@/api/plant";
|
||||
import { getPlants } from "@/api/plant";
|
||||
// Import the map component and the ShapeData type (ensure ShapeData in types.ts matches this)
|
||||
import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing";
|
||||
|
||||
interface CropDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: Partial<Crop>) => Promise<void>;
|
||||
onSubmit: (data: Partial<Cropland>) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) {
|
||||
const [selectedPlant, setSelectedPlant] = useState<string | null>(null);
|
||||
const [location, setLocation] = useState({ lat: 13.7563, lng: 100.5018 }); // Bangkok coordinates
|
||||
export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropDialogProps) {
|
||||
// --- State ---
|
||||
const [selectedPlantUUID, setSelectedPlantUUID] = useState<string | null>(null);
|
||||
// State to hold the structured GeoFeature data
|
||||
const [geoFeature, setGeoFeature] = useState<GeoFeatureData | null>(null);
|
||||
const [calculatedArea, setCalculatedArea] = useState<number | null>(null); // Keep for display
|
||||
|
||||
// --- Load Google Maps Geometry Library ---
|
||||
const geometryLib = useMapsLibrary("geometry");
|
||||
|
||||
// --- Fetch Plants ---
|
||||
const {
|
||||
data: plantData,
|
||||
isLoading: isLoadingPlants,
|
||||
isError: isErrorPlants,
|
||||
error: errorPlants,
|
||||
} = useQuery<PlantResponse>({
|
||||
queryKey: ["plants"],
|
||||
queryFn: getPlants,
|
||||
staleTime: 1000 * 60 * 60,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const plants = useMemo(() => plantData?.plants || [], [plantData]);
|
||||
const selectedPlant = useMemo(() => {
|
||||
return plants.find((p) => p.uuid === selectedPlantUUID);
|
||||
}, [plants, selectedPlantUUID]);
|
||||
|
||||
// --- Reset State on Dialog Close ---
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedPlantUUID(null);
|
||||
setGeoFeature(null); // Reset geoFeature state
|
||||
setCalculatedArea(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// --- Map Interaction Handler ---
|
||||
const handleShapeDrawn = useCallback(
|
||||
(data: ShapeData) => {
|
||||
console.log("Shape drawn:", data);
|
||||
if (!geometryLib) {
|
||||
console.warn("Geometry library not loaded yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
let feature: GeoFeatureData | null = null;
|
||||
let area: number | null = null;
|
||||
|
||||
// Helper to ensure path points are valid GeoPositions
|
||||
const mapPath = (path?: { lat: number; lng: number }[]): GeoPosition[] =>
|
||||
(path || []).map((p) => ({ lat: p.lat, lng: p.lng }));
|
||||
|
||||
// Helper to ensure position is a valid GeoPosition
|
||||
const mapPosition = (pos?: { lat: number; lng: number }): GeoPosition | null =>
|
||||
pos ? { lat: pos.lat, lng: pos.lng } : null;
|
||||
|
||||
if (data.type === "polygon" && data.path && data.path.length > 0) {
|
||||
const geoPath = mapPath(data.path);
|
||||
feature = { type: "polygon", path: geoPath };
|
||||
// Use original path for calculation if library expects {lat, lng}
|
||||
area = geometryLib.spherical.computeArea(data.path);
|
||||
console.log("Polygon drawn, Area:", area, "m²");
|
||||
} else if (data.type === "polyline" && data.path && data.path.length > 0) {
|
||||
const geoPath = mapPath(data.path);
|
||||
feature = { type: "polyline", path: geoPath };
|
||||
area = null;
|
||||
console.log("Polyline drawn, Path:", data.path);
|
||||
} else if (data.type === "marker" && data.position) {
|
||||
const geoPos = mapPosition(data.position);
|
||||
if (geoPos) {
|
||||
feature = { type: "marker", position: geoPos };
|
||||
}
|
||||
area = null;
|
||||
console.log("Marker drawn at:", data.position);
|
||||
} else {
|
||||
console.log(`Ignoring shape type: ${data.type} or empty path/position`);
|
||||
feature = null;
|
||||
area = null;
|
||||
}
|
||||
|
||||
setGeoFeature(feature);
|
||||
setCalculatedArea(area);
|
||||
},
|
||||
[geometryLib] // Depend on geometryLib
|
||||
);
|
||||
|
||||
// --- Submit Handler ---
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedPlant) return;
|
||||
// Check for geoFeature instead of just drawnPath
|
||||
if (!selectedPlantUUID || !geoFeature) {
|
||||
alert("Please select a plant and define a feature (marker, polygon, or polyline) on the map.");
|
||||
return;
|
||||
}
|
||||
// selectedPlant is derived from state using useMemo
|
||||
if (!selectedPlant) {
|
||||
alert("Selected plant not found."); // Should not happen if UUID is set
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit({
|
||||
name: plants.find((p) => p.id === selectedPlant)?.name || "",
|
||||
plantedDate: new Date(),
|
||||
status: "planned",
|
||||
});
|
||||
const cropData: Partial<Cropland> = {
|
||||
// Default name, consider making this editable
|
||||
name: `${selectedPlant.name} Field ${Math.floor(100 + Math.random() * 900)}`,
|
||||
plantId: selectedPlant.uuid,
|
||||
status: "planned", // Default status
|
||||
// Use calculatedArea if available (only for polygons), otherwise maybe 0
|
||||
// The backend might ignore this if it calculates based on GeoFeature
|
||||
landSize: calculatedArea ?? 0,
|
||||
growthStage: "Planned", // Default growth stage
|
||||
priority: 1, // Default priority
|
||||
geoFeature: geoFeature, // Add the structured geoFeature data
|
||||
// FarmID will be added in the page component mutationFn
|
||||
};
|
||||
|
||||
setSelectedPlant(null);
|
||||
onOpenChange(false);
|
||||
console.log("Submitting Cropland Data:", cropData);
|
||||
|
||||
try {
|
||||
await onSubmit(cropData);
|
||||
// State reset handled by useEffect watching 'open'
|
||||
} catch (error) {
|
||||
console.error("Submission failed in dialog:", error);
|
||||
// Optionally show an error message to the user within the dialog
|
||||
}
|
||||
};
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle></DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<DialogContent className="sm:max-w-[900px] p-0">
|
||||
<div className="grid md:grid-cols-2 h-[600px]">
|
||||
{/* Left side - Plant Selection */}
|
||||
<div className="p-6 overflow-y-auto border-r dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold mb-4">Select Plant to Grow</h2>
|
||||
<div className="space-y-4">
|
||||
{plants.map((plant) => (
|
||||
<Card
|
||||
key={plant.id}
|
||||
className={cn(
|
||||
"p-4 cursor-pointer hover:bg-muted/50 dark:hover:bg-muted/40 transition-colors",
|
||||
selectedPlant === plant.id && "border-primary dark:border-primary dark:bg-primary/5 bg-primary/5"
|
||||
)}
|
||||
onClick={() => setSelectedPlant(plant.id)}>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={plant.image || "/placeholder.svg"}
|
||||
alt={plant.name}
|
||||
className="w-20 h-20 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">{plant.name}</h3>
|
||||
{selectedPlant === plant.id && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Growth time: {plant.growthTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogContent className="sm:max-w-[950px] md:max-w-[1100px] lg:max-w-[1200px] xl:max-w-7xl p-0 max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="p-6 pb-0">
|
||||
<DialogTitle className="text-xl font-semibold">Create New Cropland</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a plant and draw the cropland boundary or mark its location on the map.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Right side - Map */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-muted/10 dark:bg-muted/20">
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<GoogleMapWithDrawing />
|
||||
<div className="flex-grow grid md:grid-cols-12 gap-0 overflow-hidden">
|
||||
{/* Left Side: Plant Selection */}
|
||||
<div className="md:col-span-4 lg:col-span-3 p-6 pt-2 border-r dark:border-slate-700 overflow-y-auto">
|
||||
<h3 className="text-md font-medium mb-4 sticky top-0 bg-background py-2">1. Select Plant</h3>
|
||||
{/* Plant selection UI */}
|
||||
{isLoadingPlants && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading plants...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isErrorPlants && (
|
||||
<div className="text-destructive flex items-center gap-2 bg-destructive/10 p-3 rounded-md">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span>Error loading plants: {(errorPlants as Error)?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingPlants && !isErrorPlants && plants.length === 0 && (
|
||||
<div className="text-center py-10 text-muted-foreground">No plants available.</div>
|
||||
)}
|
||||
{!isLoadingPlants && !isErrorPlants && plants.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{plants.map((plant) => (
|
||||
<Card
|
||||
key={plant.uuid}
|
||||
className={cn(
|
||||
"p-3 cursor-pointer hover:bg-muted/50 dark:hover:bg-muted/40 transition-colors",
|
||||
selectedPlantUUID === plant.uuid &&
|
||||
"border-2 border-primary dark:border-primary dark:bg-primary/5 bg-primary/5"
|
||||
)}
|
||||
onClick={() => setSelectedPlantUUID(plant.uuid)}>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-16 h-16 rounded-md bg-gradient-to-br from-green-100 to-lime-100 dark:from-green-900 dark:to-lime-900 flex items-center justify-center">
|
||||
<Sprout className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">
|
||||
{plant.name} <span className="text-xs text-muted-foreground">({plant.variety})</span>
|
||||
</h4>
|
||||
{selectedPlantUUID === plant.uuid && (
|
||||
<Check className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
|
||||
<p className="flex items-center">
|
||||
<CalendarDays className="h-3 w-3 mr-1" /> Maturity: ~{plant.daysToMaturity ?? "N/A"} days
|
||||
</p>
|
||||
<p className="flex items-center">
|
||||
<Thermometer className="h-3 w-3 mr-1" /> Temp: {plant.optimalTemp ?? "N/A"}°C
|
||||
</p>
|
||||
<p className="flex items-center">
|
||||
<Droplets className="h-3 w-3 mr-1" /> Water: {plant.waterNeeds ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-background dark:bg-background border-t dark:border-slate-700">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!selectedPlant}>
|
||||
Plant Crop
|
||||
</Button>
|
||||
{/* Right Side: Map */}
|
||||
<div className="md:col-span-8 lg:col-span-9 p-6 pt-2 flex flex-col overflow-hidden">
|
||||
<h3 className="text-md font-medium mb-4">2. Define Boundary / Location</h3>
|
||||
<div className="flex-grow bg-muted/30 dark:bg-muted/20 rounded-md border dark:border-slate-700 overflow-hidden relative">
|
||||
<GoogleMapWithDrawing onShapeDrawn={handleShapeDrawn} />
|
||||
|
||||
{/* Display feedback based on drawn shape */}
|
||||
{geoFeature?.type === "polygon" && calculatedArea !== null && (
|
||||
<div className="absolute bottom-2 right-2 bg-background/80 backdrop-blur-sm p-2 rounded shadow-md text-sm flex items-center gap-1">
|
||||
<Maximize className="h-3 w-3 text-blue-600" />
|
||||
Area: {calculatedArea.toFixed(2)} m²
|
||||
</div>
|
||||
)}
|
||||
{geoFeature?.type === "polyline" && geoFeature.path && (
|
||||
<div className="absolute bottom-2 right-2 bg-background/80 backdrop-blur-sm p-2 rounded shadow-md text-sm flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3 text-orange-600" />
|
||||
Boundary path defined ({geoFeature.path.length} points).
|
||||
</div>
|
||||
)}
|
||||
{geoFeature?.type === "marker" && geoFeature.position && (
|
||||
<div className="absolute bottom-2 right-2 bg-background/80 backdrop-blur-sm p-2 rounded shadow-md text-sm flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3 text-red-600" />
|
||||
Marker set at {geoFeature.position.lat.toFixed(4)}, {geoFeature.position.lng.toFixed(4)}.
|
||||
</div>
|
||||
)}
|
||||
{!geometryLib && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Loading map tools...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Use the drawing tools (Polygon <Maximize className="inline h-3 w-3" />, Polyline{" "}
|
||||
<MapPin className="inline h-3 w-3" />, Marker <MapPin className="inline h-3 w-3 text-red-500" />) above
|
||||
the map. Area is calculated for polygons.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialog Footer */}
|
||||
<DialogFooter className="p-6 pt-4 border-t dark:border-slate-700 mt-auto">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/* Disable submit if no plant OR no feature is selected */}
|
||||
<Button onClick={handleSubmit} disabled={!selectedPlantUUID || !geoFeature || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Cropland"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -4,12 +4,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LineChart, Sprout, Droplets, Sun } from "lucide-react";
|
||||
import type { Crop, CropAnalytics } from "@/types";
|
||||
import type { CropAnalytics, Cropland } from "@/types";
|
||||
|
||||
interface AnalyticsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
crop: Crop;
|
||||
crop: Cropland;
|
||||
analytics: CropAnalytics;
|
||||
}
|
||||
|
||||
|
||||
@ -1,30 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Sprout,
|
||||
LineChart,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
Droplets,
|
||||
Sun,
|
||||
ThermometerSun,
|
||||
Timer,
|
||||
ListCollapse,
|
||||
Calendar,
|
||||
Leaf,
|
||||
CloudRain,
|
||||
Wind,
|
||||
Home,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
LeafIcon,
|
||||
History,
|
||||
Bot,
|
||||
} 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 { Separator } from "@/components/ui/separator";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
@ -32,56 +31,121 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ChatbotDialog } from "./chatbot-dialog";
|
||||
import { AnalyticsDialog } from "./analytics-dialog";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import type { Crop, CropAnalytics } from "@/types";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import type { Cropland, CropAnalytics, Farm } from "@/types";
|
||||
import { getFarm } from "@/api/farm";
|
||||
import { getPlants, PlantResponse } from "@/api/plant";
|
||||
import { getCropById, fetchCropAnalytics } from "@/api/crop";
|
||||
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
||||
import { fetchCropById, fetchAnalyticsByCropId } from "@/api/farm";
|
||||
|
||||
interface CropDetailPageParams {
|
||||
farmId: string;
|
||||
cropId: string;
|
||||
}
|
||||
|
||||
export default function CropDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<CropDetailPageParams>;
|
||||
}) {
|
||||
export default function CropDetailPage() {
|
||||
const router = useRouter();
|
||||
const [crop, setCrop] = useState<Crop | null>(null);
|
||||
const [analytics, setAnalytics] = useState<CropAnalytics | null>(null);
|
||||
const params = useParams<{ farmId: string; cropId: string }>();
|
||||
const { farmId, cropId } = params;
|
||||
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const resolvedParams = await params;
|
||||
const cropData = await fetchCropById(resolvedParams.cropId);
|
||||
const analyticsData = await fetchAnalyticsByCropId(resolvedParams.cropId);
|
||||
setCrop(cropData);
|
||||
setAnalytics(analyticsData);
|
||||
}
|
||||
fetchData();
|
||||
}, [params]);
|
||||
// --- Fetch Farm Data ---
|
||||
const { data: farm, isLoading: isLoadingFarm } = useQuery<Farm>({
|
||||
queryKey: ["farm", farmId],
|
||||
queryFn: () => getFarm(farmId),
|
||||
enabled: !!farmId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!crop || !analytics) {
|
||||
// --- Fetch Cropland Data ---
|
||||
const {
|
||||
data: cropland,
|
||||
isLoading: isLoadingCropland,
|
||||
isError: isErrorCropland,
|
||||
error: errorCropland,
|
||||
} = useQuery<Cropland>({
|
||||
queryKey: ["crop", cropId],
|
||||
queryFn: () => getCropById(cropId),
|
||||
enabled: !!cropId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// --- Fetch All Plants Data ---
|
||||
const {
|
||||
data: plantData,
|
||||
isLoading: isLoadingPlants,
|
||||
isError: isErrorPlants,
|
||||
error: errorPlants,
|
||||
} = useQuery<PlantResponse>({
|
||||
queryKey: ["plants"],
|
||||
queryFn: getPlants,
|
||||
staleTime: 1000 * 60 * 60,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// --- Derive specific Plant ---
|
||||
const plant = useMemo(() => {
|
||||
if (!cropland?.plantId || !plantData?.plants) return null;
|
||||
return plantData.plants.find((p) => p.uuid === cropland.plantId);
|
||||
}, [cropland, plantData]);
|
||||
|
||||
// --- Fetch Crop Analytics Data ---
|
||||
const {
|
||||
data: analytics, // Type is CropAnalytics | null
|
||||
isLoading: isLoadingAnalytics,
|
||||
isError: isErrorAnalytics,
|
||||
error: errorAnalytics,
|
||||
} = useQuery<CropAnalytics | null>({
|
||||
queryKey: ["cropAnalytics", cropId],
|
||||
queryFn: () => fetchCropAnalytics(cropId),
|
||||
enabled: !!cropId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// --- Combined Loading and Error States ---
|
||||
const isLoading = isLoadingFarm || isLoadingCropland || isLoadingPlants || isLoadingAnalytics;
|
||||
const isError = isErrorCropland || isErrorPlants || isErrorAnalytics; // Prioritize crop/analytics errors
|
||||
const error = errorCropland || errorPlants || errorAnalytics;
|
||||
|
||||
// --- Loading State ---
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background text-foreground">
|
||||
Loading...
|
||||
<Loader2 className="h-8 w-8 animate-spin text-green-600" />
|
||||
<span className="ml-2">Loading crop details...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Error State ---
|
||||
if (isError || !cropland) {
|
||||
console.error("Error loading crop details:", error);
|
||||
return (
|
||||
<div className="min-h-screen container max-w-7xl p-6 mx-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit gap-2 text-muted-foreground mb-6"
|
||||
onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Button>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error Loading Crop Details</AlertTitle>
|
||||
<AlertDescription>
|
||||
{isErrorCropland
|
||||
? `Crop with ID ${cropId} not found or could not be loaded.`
|
||||
: (error as Error)?.message || "An unexpected error occurred."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Data available, render page ---
|
||||
const healthColors = {
|
||||
good: "text-green-500 bg-green-50 dark:bg-green-900",
|
||||
warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900",
|
||||
critical: "text-red-500 bg-red-50 dark:bg-red-900",
|
||||
good: "text-green-500 bg-green-50 dark:bg-green-900 border-green-200",
|
||||
warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900 border-yellow-200",
|
||||
critical: "text-red-500 bg-red-50 dark:bg-red-900 border-red-200",
|
||||
};
|
||||
const healthStatus = analytics?.plantHealth || "good";
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
@ -93,7 +157,7 @@ export default function CropDetailPage({
|
||||
},
|
||||
{
|
||||
title: "Chat Assistant",
|
||||
icon: MessageSquare,
|
||||
icon: Bot,
|
||||
description: "Get help and advice",
|
||||
onClick: () => setIsChatOpen(true),
|
||||
color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300",
|
||||
@ -102,96 +166,89 @@ export default function CropDetailPage({
|
||||
title: "Crop Details",
|
||||
icon: ListCollapse,
|
||||
description: "View detailed information",
|
||||
onClick: () => console.log("Details clicked"),
|
||||
color:
|
||||
"bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300",
|
||||
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"),
|
||||
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 daysToMaturity = plant?.daysToMaturity; // Use camelCase
|
||||
const expectedHarvestDate =
|
||||
plantedDate && daysToMaturity ? new Date(plantedDate.getTime() + daysToMaturity * 24 * 60 * 60 * 1000) : null;
|
||||
|
||||
const growthProgress = analytics?.growthProgress ?? 0; // Get from analytics
|
||||
const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize} ha` : "N/A"; // Use camelCase
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="container max-w-7xl p-6 mx-auto">
|
||||
{/* Breadcrumbs */}
|
||||
<nav className="flex items-center text-sm text-muted-foreground mb-4">
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary"
|
||||
onClick={() => router.push("/")}>
|
||||
<Home className="h-3.5 w-3.5 mr-1" />
|
||||
Home
|
||||
</Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1" />
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary"
|
||||
onClick={() => router.push("/farms")}>
|
||||
Farms
|
||||
</Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1" />
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary max-w-[150px] truncate"
|
||||
onClick={() => router.push(`/farms/${farmId}`)}>
|
||||
{farm?.name || "Farm"} {/* Use camelCase */}
|
||||
</Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1" />
|
||||
<span className="text-foreground font-medium truncate">{cropland.name || "Crop"}</span> {/* Use camelCase */}
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-6 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 text-green-700 dark:text-green-300 hover:text-green-800 dark:hover:text-green-200 hover:bg-green-100/50 dark:hover:bg-green-800/50"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit gap-2 text-muted-foreground"
|
||||
onClick={() => router.push(`/farms/${farmId}`)}>
|
||||
<ArrowLeft className="h-4 w-4" /> Back to Farm
|
||||
</Button>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Calendar className="h-4 w-4" /> Timeline
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80">
|
||||
<div className="flex justify-between space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg" />
|
||||
<AvatarFallback>
|
||||
<Sprout className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">Growth Timeline</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Planted on {crop.plantedDate.toLocaleDateString()}
|
||||
</p>
|
||||
<div className="flex items-center pt-2">
|
||||
<Separator className="w-full" />
|
||||
<span className="mx-2 text-xs text-muted-foreground">
|
||||
{Math.floor(analytics.growthProgress)}% Complete
|
||||
</span>
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Hover Card (removed for simplicity, add back if needed) */}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{crop.name}</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1> {/* Use camelCase */}
|
||||
<p className="text-muted-foreground">
|
||||
{crop.variety} • {crop.area}
|
||||
{plant?.variety || "Unknown Variety"} • {displayArea} {/* Use camelCase */}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${healthColors[analytics.plantHealth]} border`}
|
||||
>
|
||||
Health Score: {crop.healthScore}%
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300"
|
||||
>
|
||||
Growing
|
||||
<Badge variant="outline" className={`${healthColors[healthStatus]} border capitalize`}>
|
||||
{cropland.status} {/* Use camelCase */}
|
||||
</Badge>
|
||||
</div>
|
||||
{crop.expectedHarvest ? (
|
||||
{expectedHarvestDate ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Expected harvest:{" "}
|
||||
{crop.expectedHarvest.toLocaleDateString()}
|
||||
Expected harvest: {expectedHarvestDate.toLocaleDateString()}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Expected harvest date not available
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Expected harvest date not available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -208,138 +265,123 @@ export default function CropDetailPage({
|
||||
<Button
|
||||
key={action.title}
|
||||
variant="outline"
|
||||
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105`}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105 border-border/30`}
|
||||
onClick={action.onClick}>
|
||||
<div
|
||||
className={`p-3 rounded-lg ${action.color} group-hover:scale-110 transition-transform`}
|
||||
>
|
||||
className={`p-3 rounded-lg ${action.color.replace(
|
||||
"text-",
|
||||
"bg-"
|
||||
)}/20 group-hover:scale-110 transition-transform`}>
|
||||
<action.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium mb-1">{action.title}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{action.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{action.description}</p>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Environmental Metrics */}
|
||||
<Card className="border-green-100 dark:border-green-700">
|
||||
<Card className="border-border/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Environmental Conditions</CardTitle>
|
||||
<CardDescription>
|
||||
Real-time monitoring of growing conditions
|
||||
</CardDescription>
|
||||
<CardDescription>Real-time monitoring data</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
icon: ThermometerSun,
|
||||
label: "Temperature",
|
||||
value: `${analytics.temperature}°C`,
|
||||
value: analytics?.temperature ? `${analytics.temperature}°C` : "N/A",
|
||||
color: "text-orange-500 dark:text-orange-300",
|
||||
bg: "bg-orange-50 dark:bg-orange-900",
|
||||
},
|
||||
{
|
||||
icon: Droplets,
|
||||
label: "Humidity",
|
||||
value: `${analytics.humidity}%`,
|
||||
value: analytics?.humidity ? `${analytics.humidity}%` : "N/A",
|
||||
color: "text-blue-500 dark:text-blue-300",
|
||||
bg: "bg-blue-50 dark:bg-blue-900",
|
||||
},
|
||||
{
|
||||
icon: Sun,
|
||||
label: "Sunlight",
|
||||
value: `${analytics.sunlight}%`,
|
||||
value: analytics?.sunlight ? `${analytics.sunlight}%` : "N/A",
|
||||
color: "text-yellow-500 dark:text-yellow-300",
|
||||
bg: "bg-yellow-50 dark:bg-yellow-900",
|
||||
},
|
||||
{
|
||||
icon: Leaf,
|
||||
label: "Soil Moisture",
|
||||
value: `${analytics.soilMoisture}%`,
|
||||
value: analytics?.soilMoisture ? `${analytics.soilMoisture}%` : "N/A",
|
||||
color: "text-green-500 dark:text-green-300",
|
||||
bg: "bg-green-50 dark:bg-green-900",
|
||||
},
|
||||
{
|
||||
icon: Wind,
|
||||
label: "Wind Speed",
|
||||
value: analytics.windSpeed,
|
||||
value: analytics?.windSpeed ?? "N/A",
|
||||
color: "text-gray-500 dark:text-gray-300",
|
||||
bg: "bg-gray-50 dark:bg-gray-900",
|
||||
},
|
||||
{
|
||||
icon: CloudRain,
|
||||
label: "Rainfall",
|
||||
value: analytics.rainfall,
|
||||
value: analytics?.rainfall ?? "N/A",
|
||||
color: "text-indigo-500 dark:text-indigo-300",
|
||||
bg: "bg-indigo-50 dark:bg-indigo-900",
|
||||
},
|
||||
].map((metric) => (
|
||||
<Card
|
||||
key={metric.label}
|
||||
className="border-none shadow-none bg-gradient-to-br from-white to-gray-50/50 dark:from-slate-800 dark:to-slate-700/50"
|
||||
>
|
||||
<Card key={metric.label} className="border-border/30 shadow-none bg-card dark:bg-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2 rounded-lg ${metric.bg}`}>
|
||||
<metric.icon
|
||||
className={`h-4 w-4 ${metric.color}`}
|
||||
/>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${metric.bg}/50`}>
|
||||
<metric.icon className={`h-4 w-4 ${metric.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{metric.label}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold tracking-tight">
|
||||
{metric.value}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{metric.label}</p>
|
||||
<p className="text-xl font-semibold tracking-tight">{metric.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Growth Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Growth Progress</span>
|
||||
<span className="text-muted-foreground">
|
||||
{analytics.growthProgress}%
|
||||
</span>
|
||||
<span className="text-muted-foreground">{growthProgress}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={analytics.growthProgress}
|
||||
className="h-2"
|
||||
/>
|
||||
<Progress value={growthProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Next Action Card */}
|
||||
<Card className="border-green-100 dark:border-green-700 bg-green-50/50 dark:bg-green-900/50">
|
||||
<Card className="border-blue-100 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-900/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-800">
|
||||
<Timer className="h-4 w-4 text-green-600 dark:text-green-300" />
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-800">
|
||||
<Timer className="h-4 w-4 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-1">
|
||||
Next Action Required
|
||||
</p>
|
||||
<p className="font-medium mb-1">Next Action Required</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{analytics.nextAction}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Due by{" "}
|
||||
{analytics.nextActionDue.toLocaleDateString()}
|
||||
{analytics?.nextAction || "Check crop status"}
|
||||
</p>
|
||||
{analytics?.nextActionDue && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Due by {new Date(analytics.nextActionDue).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
{!analytics?.nextAction && (
|
||||
<p className="text-xs text-muted-foreground mt-1">No immediate actions required.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -349,13 +391,18 @@ export default function CropDetailPage({
|
||||
</Card>
|
||||
|
||||
{/* Map Section */}
|
||||
<Card className="border-green-100 dark:border-green-700">
|
||||
<Card className="border-border/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Field Map</CardTitle>
|
||||
<CardDescription>View and manage crop location</CardDescription>
|
||||
<CardTitle>Cropland Location / Boundary</CardTitle>
|
||||
<CardDescription>Visual representation on the farm</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 h-[400px]">
|
||||
<GoogleMapWithDrawing />
|
||||
<CardContent className="p-0 h-[400px] overflow-hidden rounded-b-lg">
|
||||
{/* TODO: Ensure GoogleMapWithDrawing correctly parses and displays GeoFeatureData */}
|
||||
<GoogleMapWithDrawing
|
||||
initialFeatures={cropland.geoFeature ? [cropland.geoFeature] : undefined}
|
||||
drawingMode={null}
|
||||
editable={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@ -363,83 +410,64 @@ export default function CropDetailPage({
|
||||
{/* Right Column */}
|
||||
<div className="md:col-span-4 space-y-6">
|
||||
{/* Nutrient Levels */}
|
||||
<Card className="border-green-100 dark:border-green-700">
|
||||
<Card className="border-border/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Nutrient Levels</CardTitle>
|
||||
<CardDescription>Current soil composition</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LeafIcon className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
Nutrient Levels
|
||||
</CardTitle>
|
||||
<CardDescription>Soil composition (if available)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
name: "Nitrogen (N)",
|
||||
value: analytics.nutrientLevels.nitrogen,
|
||||
value: analytics?.nutrientLevels?.nitrogen,
|
||||
color: "bg-blue-500 dark:bg-blue-700",
|
||||
},
|
||||
{
|
||||
name: "Phosphorus (P)",
|
||||
value: analytics.nutrientLevels.phosphorus,
|
||||
value: analytics?.nutrientLevels?.phosphorus,
|
||||
color: "bg-yellow-500 dark:bg-yellow-700",
|
||||
},
|
||||
{
|
||||
name: "Potassium (K)",
|
||||
value: analytics.nutrientLevels.potassium,
|
||||
value: analytics?.nutrientLevels?.potassium,
|
||||
color: "bg-green-500 dark:bg-green-700",
|
||||
},
|
||||
].map((nutrient) => (
|
||||
<div key={nutrient.name} className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{nutrient.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{nutrient.value}%
|
||||
</span>
|
||||
<span className="text-muted-foreground">{nutrient.value ?? "N/A"}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={nutrient.value}
|
||||
className={`h-2 ${nutrient.color}`}
|
||||
value={nutrient.value ?? 0}
|
||||
className={`h-2 ${
|
||||
nutrient.value !== null && nutrient.value !== undefined ? nutrient.color : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!analytics?.nutrientLevels && (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">Nutrient data not available.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card className="border-green-100 dark:border-green-700">
|
||||
<Card className="border-border/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>Latest updates and changes</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
<CardDescription>Latest updates (placeholder)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[300px] pr-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="mb-4 last:mb-0">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800">
|
||||
<Activity icon={i} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{
|
||||
[
|
||||
"Irrigation completed",
|
||||
"Nutrient levels checked",
|
||||
"Growth measurement taken",
|
||||
"Pest inspection completed",
|
||||
"Soil pH tested",
|
||||
][i]
|
||||
}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
2 hours ago
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{i < 4 && (
|
||||
<Separator className="my-4 dark:bg-slate-700" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-center py-10 text-muted-foreground">No recent activity logged.</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -447,38 +475,30 @@ export default function CropDetailPage({
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<ChatbotDialog
|
||||
open={isChatOpen}
|
||||
onOpenChange={setIsChatOpen}
|
||||
cropName={crop.name}
|
||||
/>
|
||||
<AnalyticsDialog
|
||||
open={isAnalyticsOpen}
|
||||
onOpenChange={setIsAnalyticsOpen}
|
||||
crop={crop}
|
||||
analytics={analytics}
|
||||
/>
|
||||
<ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={cropland.name || "this crop"} />
|
||||
{/* Ensure AnalyticsDialog uses the correct props */}
|
||||
{analytics && (
|
||||
<AnalyticsDialog
|
||||
open={isAnalyticsOpen}
|
||||
onOpenChange={setIsAnalyticsOpen}
|
||||
// The dialog expects a `Crop` type, but we have `Cropland` and `CropAnalytics`
|
||||
// We need to construct a simplified `Crop` object or update the dialog prop type
|
||||
crop={{
|
||||
// Constructing a simplified Crop object
|
||||
uuid: cropland.uuid,
|
||||
farmId: cropland.farmId,
|
||||
name: cropland.name,
|
||||
createdAt: cropland.createdAt, // Use createdAt as plantedDate
|
||||
status: cropland.status,
|
||||
variety: plant?.variety, // Get from plant data
|
||||
area: `${cropland.landSize} ha`, // Convert landSize
|
||||
progress: growthProgress, // Use calculated/fetched progress
|
||||
// healthScore might map to plantHealth
|
||||
}}
|
||||
analytics={analytics} // Pass fetched analytics
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper component to render an activity icon based on the index.
|
||||
*/
|
||||
function Activity({ icon }: { icon: number }) {
|
||||
const icons = [
|
||||
<Droplets key="0" className="h-4 w-4 text-blue-500 dark:text-blue-300" />,
|
||||
<Leaf key="1" className="h-4 w-4 text-green-500 dark:text-green-300" />,
|
||||
<LineChart
|
||||
key="2"
|
||||
className="h-4 w-4 text-purple-500 dark:text-purple-300"
|
||||
/>,
|
||||
<Sprout key="3" className="h-4 w-4 text-yellow-500 dark:text-yellow-300" />,
|
||||
<ThermometerSun
|
||||
key="4"
|
||||
className="h-4 w-4 text-orange-500 dark:text-orange-300"
|
||||
/>,
|
||||
];
|
||||
return icons[icon];
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
MapPin,
|
||||
@ -13,10 +14,11 @@ import {
|
||||
Loader2,
|
||||
Home,
|
||||
ChevronRight,
|
||||
Droplets,
|
||||
Sun,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CropDialog } from "./crop-dialog";
|
||||
@ -24,105 +26,138 @@ import { CropCard } from "./crop-card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { Farm, Crop } from "@/types";
|
||||
import { fetchFarmDetails } from "@/api/farm";
|
||||
|
||||
/**
|
||||
* Used in Next.js; params is now a Promise and must be unwrapped with React.use()
|
||||
*/
|
||||
interface FarmDetailPageProps {
|
||||
params: Promise<{ farmId: string }>;
|
||||
}
|
||||
|
||||
export default function FarmDetailPage({ params }: FarmDetailPageProps) {
|
||||
// Unwrap the promised params using React.use() (experimental)
|
||||
const resolvedParams = React.use(params);
|
||||
import type { Farm, Cropland } from "@/types";
|
||||
import { getCropsByFarmId, createCrop, CropResponse } from "@/api/crop";
|
||||
import { getFarm } from "@/api/farm";
|
||||
|
||||
// ===================================================================
|
||||
// Page Component: FarmDetailPage
|
||||
// - Manages farm details, crop listings, filter tabs, and crop creation.
|
||||
// - Performs API requests via React Query.
|
||||
// ===================================================================
|
||||
export default function FarmDetailPage() {
|
||||
// ---------------------------------------------------------------
|
||||
// Routing and URL Params
|
||||
// ---------------------------------------------------------------
|
||||
const params = useParams<{ farmId: string }>();
|
||||
const farmId = params.farmId;
|
||||
const router = useRouter();
|
||||
const [farm, setFarm] = useState<Farm | null>(null);
|
||||
const [crops, setCrops] = useState<Crop[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Local State for dialog and crop filter management
|
||||
// ---------------------------------------------------------------
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<string>("all");
|
||||
|
||||
// Fetch farm details on initial render using the resolved params
|
||||
useEffect(() => {
|
||||
async function loadFarmDetails() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const { farm, crops } = await fetchFarmDetails(resolvedParams.farmId);
|
||||
setFarm(farm);
|
||||
setCrops(crops);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message === "FARM_NOT_FOUND") {
|
||||
router.push("/not-found");
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("An unknown error occurred");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------
|
||||
// Data fetching: Farm details and Crops using React Query.
|
||||
// - See: https://tanstack.com/query
|
||||
// ---------------------------------------------------------------
|
||||
const {
|
||||
data: farm,
|
||||
isLoading: isLoadingFarm,
|
||||
isError: isErrorFarm,
|
||||
error: errorFarm,
|
||||
} = useQuery<Farm>({
|
||||
queryKey: ["farm", farmId],
|
||||
queryFn: () => getFarm(farmId),
|
||||
enabled: !!farmId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
loadFarmDetails();
|
||||
}, [resolvedParams.farmId, router]);
|
||||
const {
|
||||
data: cropData, // Changed name to avoid conflict
|
||||
isLoading: isLoadingCrops,
|
||||
isError: isErrorCrops,
|
||||
error: errorCrops,
|
||||
} = useQuery<CropResponse>({
|
||||
// Use CropResponse type
|
||||
queryKey: ["crops", farmId],
|
||||
queryFn: () => getCropsByFarmId(farmId), // Use updated API function name
|
||||
enabled: !!farmId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles adding a new crop.
|
||||
*/
|
||||
const handleAddCrop = async (data: Partial<Crop>) => {
|
||||
try {
|
||||
// Simulate API delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
const newCrop: Crop = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
farmId: farm!.id,
|
||||
name: data.name!,
|
||||
plantedDate: data.plantedDate!,
|
||||
status: data.status!,
|
||||
variety: data.variety || "Standard",
|
||||
area: data.area || "0 hectares",
|
||||
healthScore: data.status === "growing" ? 85 : 0,
|
||||
progress: data.status === "growing" ? 10 : 0,
|
||||
};
|
||||
|
||||
setCrops((prev) => [newCrop, ...prev]);
|
||||
|
||||
// Update the farm's crop count
|
||||
if (farm) {
|
||||
setFarm({ ...farm, crops: farm.crops + 1 });
|
||||
}
|
||||
// ---------------------------------------------------------------
|
||||
// Mutation: Create Crop
|
||||
// - After creation, invalidate queries to refresh data.
|
||||
// ---------------------------------------------------------------
|
||||
const croplands = useMemo(() => cropData?.croplands || [], [cropData]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (newCropData: Partial<Cropland>) => createCrop({ ...newCropData, farmId: farmId }), // Pass farmId here
|
||||
onSuccess: (newlyCreatedCrop) => {
|
||||
console.log("Successfully created crop:", newlyCreatedCrop);
|
||||
queryClient.invalidateQueries({ queryKey: ["crops", farmId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["farm", farmId] }); // Invalidate farm too to update crop count potentially
|
||||
setIsDialogOpen(false);
|
||||
} catch (err) {
|
||||
setError("Failed to add crop. Please try again.");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to add crop:", error);
|
||||
// TODO: Show user-friendly error message (e.g., using toast)
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddCrop = async (data: Partial<Cropland>) => {
|
||||
await mutation.mutateAsync(data);
|
||||
};
|
||||
|
||||
// Filter crops based on the active filter
|
||||
const filteredCrops = crops.filter((crop) => activeFilter === "all" || crop.status === activeFilter);
|
||||
// ---------------------------------------------------------------
|
||||
// Determine combined loading and error states from individual queries.
|
||||
// ---------------------------------------------------------------
|
||||
const isLoading = isLoadingFarm || isLoadingCrops;
|
||||
const isError = isErrorFarm || isErrorCrops;
|
||||
const error = errorFarm || errorCrops;
|
||||
|
||||
// Calculate crop counts grouped by status
|
||||
const cropCounts = {
|
||||
all: crops.length,
|
||||
growing: crops.filter((crop) => crop.status === "growing").length,
|
||||
planned: crops.filter((crop) => crop.status === "planned").length,
|
||||
harvested: crops.filter((crop) => crop.status === "harvested").length,
|
||||
};
|
||||
// ---------------------------------------------------------------
|
||||
// Filter crops based on the active filter tab.
|
||||
// ---------------------------------------------------------------
|
||||
const filteredCrops = useMemo(() => {
|
||||
// Renamed from filteredCrops
|
||||
return croplands.filter(
|
||||
(crop) => activeFilter === "all" || crop.status.toLowerCase() === activeFilter.toLowerCase() // Use camelCase status
|
||||
);
|
||||
}, [croplands, activeFilter]);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Calculate counts for each crop status to display in tabs.
|
||||
// ---------------------------------------------------------------
|
||||
const possibleStatuses = ["growing", "planned", "harvested", "fallow"]; // Use lowercase
|
||||
const cropCounts = useMemo(() => {
|
||||
return croplands.reduce(
|
||||
(acc, crop) => {
|
||||
const status = crop.status.toLowerCase(); // Use camelCase status
|
||||
if (acc[status] !== undefined) {
|
||||
acc[status]++;
|
||||
} else {
|
||||
acc["other"] = (acc["other"] || 0) + 1; // Count unknown statuses
|
||||
}
|
||||
acc.all++;
|
||||
return acc;
|
||||
},
|
||||
{ all: 0, ...Object.fromEntries(possibleStatuses.map((s) => [s, 0])) } as Record<string, number>
|
||||
);
|
||||
}, [croplands]);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Derive the unique statuses from the crops list for the tabs.
|
||||
// ---------------------------------------------------------------
|
||||
const availableStatuses = useMemo(() => {
|
||||
return ["all", ...new Set(croplands.map((crop) => crop.status.toLowerCase()))]; // Use camelCase status
|
||||
}, [croplands]);
|
||||
|
||||
// ===================================================================
|
||||
// Render: Main page layout segmented into breadcrumbs, farm cards,
|
||||
// crop management, and the crop add dialog.
|
||||
// ===================================================================
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="container max-w-7xl p-6 mx-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Breadcrumbs */}
|
||||
{/* ------------------------------
|
||||
Breadcrumbs Navigation Section
|
||||
------------------------------ */}
|
||||
<nav className="flex items-center text-sm text-muted-foreground">
|
||||
<Button
|
||||
variant="link"
|
||||
@ -142,7 +177,9 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {
|
||||
<span className="text-foreground font-medium truncate">{farm?.name || "Farm Details"}</span>
|
||||
</nav>
|
||||
|
||||
{/* Back button */}
|
||||
{/* ------------------------------
|
||||
Back Navigation Button
|
||||
------------------------------ */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -151,16 +188,23 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {
|
||||
<ArrowLeft className="h-4 w-4" /> Back to Farms
|
||||
</Button>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
{/* ------------------------------
|
||||
Error and Loading States
|
||||
------------------------------ */}
|
||||
{isError && !isLoadingFarm && !farm && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertTitle>Error Loading Farm</AlertTitle>
|
||||
<AlertDescription>{(error as Error)?.message || "Could not load farm details."}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isErrorCrops && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error Loading Crops</AlertTitle>
|
||||
<AlertDescription>{(errorCrops as Error)?.message || "Could not load crop data."}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" />
|
||||
@ -168,22 +212,24 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Farm details */}
|
||||
{!isLoading && !error && farm && (
|
||||
{/* ------------------------------
|
||||
Farm Details and Statistics
|
||||
------------------------------ */}
|
||||
{!isLoadingFarm && !isErrorFarm && farm && (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-12">
|
||||
{/* Farm info card */}
|
||||
{/* Farm Info Card */}
|
||||
<Card className="md:col-span-8">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200">
|
||||
{farm.type}
|
||||
{farm.farmType}
|
||||
</Badge>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
Created {farm.createdAt.toLocaleDateString()}
|
||||
Created {new Date(farm.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 mt-2">
|
||||
@ -194,7 +240,7 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {
|
||||
<h1 className="text-2xl font-bold">{farm.name}</h1>
|
||||
<div className="flex items-center text-muted-foreground mt-1">
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
{farm.location}
|
||||
Lat: {farm.lat?.toFixed(4)}, Lon: {farm.lon?.toFixed(4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -203,248 +249,141 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Area</p>
|
||||
<p className="text-lg font-semibold">{farm.area}</p>
|
||||
<p className="text-lg font-semibold">{farm.totalSize}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Total Crops</p>
|
||||
<p className="text-lg font-semibold">{farm.crops}</p>
|
||||
<p className="text-lg font-semibold">{isLoadingCrops ? "..." : cropCounts.all ?? 0}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Growing Crops</p>
|
||||
<p className="text-lg font-semibold">{cropCounts.growing}</p>
|
||||
<p className="text-xs text-muted-foreground">Growing</p>
|
||||
<p className="text-lg font-semibold">{isLoadingCrops ? "..." : cropCounts.growing ?? 0}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">Harvested</p>
|
||||
<p className="text-lg font-semibold">{cropCounts.harvested}</p>
|
||||
<p className="text-lg font-semibold">{isLoadingCrops ? "..." : cropCounts.harvested ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Weather card */}
|
||||
{/* Weather Overview Card */}
|
||||
<Card className="md:col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Current Conditions</CardTitle>
|
||||
<CardDescription>Weather at your farm location</CardDescription>
|
||||
<CardTitle className="text-lg font-semibold flex items-center">
|
||||
<Sun className="h-5 w-5 mr-2 text-yellow-500" /> Weather Overview
|
||||
</CardTitle>
|
||||
<CardDescription>Current conditions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="p-2 rounded-lg bg-orange-50 dark:bg-orange-900">
|
||||
<Sun className="h-4 w-4 text-orange-500 dark:text-orange-200" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Temperature</p>
|
||||
<p className="text-xl font-semibold">{farm.weather?.temperature}°C</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900">
|
||||
<Droplets className="h-4 w-4 text-blue-500 dark:text-blue-200" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Humidity</p>
|
||||
<p className="text-xl font-semibold">{farm.weather?.humidity}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="p-2 rounded-lg bg-yellow-50 dark:bg-yellow-900">
|
||||
<Sun className="h-4 w-4 text-yellow-500 dark:text-yellow-200" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Sunlight</p>
|
||||
<p className="text-xl font-semibold">{farm.weather?.sunlight}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="p-2 rounded-lg bg-gray-50 dark:bg-gray-900">
|
||||
<Wind className="h-4 w-4 text-gray-500 dark:text-gray-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Rainfall</p>
|
||||
<p className="text-xl font-semibold">{farm.weather?.rainfall}</p>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Temperature</span>
|
||||
<span className="font-medium">25°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Humidity</span>
|
||||
<span className="font-medium">60%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Wind</span>
|
||||
<span className="font-medium">10 km/h</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Rainfall (24h)</span>
|
||||
<span className="font-medium">2 mm</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Crops section */}
|
||||
{/* ------------------------------
|
||||
Crops Section: List and Filtering Tabs
|
||||
------------------------------ */}
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center">
|
||||
<LayoutGrid className="h-5 w-5 mr-2 text-green-600 dark:text-green-300" />
|
||||
Crops
|
||||
Crops / Croplands
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage and monitor all crops in this farm</p>
|
||||
<p className="text-sm text-muted-foreground">Manage and monitor all croplands in this farm</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto"
|
||||
disabled={mutation.isPending}>
|
||||
{mutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
Add New Crop
|
||||
</Button>
|
||||
</div>
|
||||
{mutation.isError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Failed to Add Crop</AlertTitle>
|
||||
<AlertDescription>
|
||||
{(mutation.error as Error)?.message || "Could not add the crop. Please try again."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="all" className="mt-6">
|
||||
<Tabs value={activeFilter} onValueChange={setActiveFilter} className="mt-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all" onClick={() => setActiveFilter("all")}>
|
||||
All Crops ({cropCounts.all})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="growing" onClick={() => setActiveFilter("growing")}>
|
||||
Growing ({cropCounts.growing})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="planned" onClick={() => setActiveFilter("planned")}>
|
||||
Planned ({cropCounts.planned})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="harvested" onClick={() => setActiveFilter("harvested")}>
|
||||
Harvested ({cropCounts.harvested})
|
||||
</TabsTrigger>
|
||||
{availableStatuses.map((status) => (
|
||||
<TabsTrigger key={status} value={status} className="capitalize">
|
||||
{status === "all" ? "All" : status} ({isLoadingCrops ? "..." : cropCounts[status] ?? 0})
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="mt-6">
|
||||
{filteredCrops.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
|
||||
<div className="bg-green-100 dark:bg-green-900 p-3 rounded-full mb-4">
|
||||
<Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">No crops found</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
{activeFilter === "all"
|
||||
? "You haven't added any crops to this farm yet."
|
||||
: `No ${activeFilter} crops found. Try a different filter.`}
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add your first crop
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredCrops.map((crop, index) => (
|
||||
<motion.div
|
||||
key={crop.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}>
|
||||
<CropCard
|
||||
crop={crop}
|
||||
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Growing tab */}
|
||||
<TabsContent value="growing" className="mt-6">
|
||||
{filteredCrops.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
|
||||
<div className="bg-green-100 dark:bg-green-900 p-3 rounded-full mb-4">
|
||||
<Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">No growing crops</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
You don't have any growing crops in this farm yet.
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add a growing crop
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredCrops.map((crop, index) => (
|
||||
<motion.div
|
||||
key={crop.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}>
|
||||
<CropCard
|
||||
crop={crop}
|
||||
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Planned tab */}
|
||||
<TabsContent value="planned" className="mt-6">
|
||||
{filteredCrops.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
|
||||
<h3 className="text-xl font-medium mb-2">No planned crops</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
You don't have any planned crops in this farm yet.
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Plan a new crop
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredCrops.map((crop, index) => (
|
||||
<motion.div
|
||||
key={crop.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}>
|
||||
<CropCard
|
||||
crop={crop}
|
||||
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Harvested tab */}
|
||||
<TabsContent value="harvested" className="mt-6">
|
||||
{filteredCrops.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
|
||||
<h3 className="text-xl font-medium mb-2">No harvested crops</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
You don't have any harvested crops in this farm yet.
|
||||
</p>
|
||||
<Button onClick={() => setActiveFilter("all")} className="gap-2">
|
||||
View all crops
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredCrops.map((crop, index) => (
|
||||
<motion.div
|
||||
key={crop.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}>
|
||||
<CropCard
|
||||
crop={crop}
|
||||
onClick={() => router.push(`/farms/${crop.farmId}/crops/${crop.id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
{isLoadingCrops ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 text-green-600 animate-spin" />
|
||||
</div>
|
||||
) : isErrorCrops ? (
|
||||
<div className="text-center py-12 text-destructive">Failed to load crops.</div>
|
||||
) : (
|
||||
availableStatuses.map((status) => (
|
||||
<TabsContent key={status} value={status} className="mt-6">
|
||||
{filteredCrops.length === 0 && activeFilter === status ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 dark:bg-muted/30 rounded-lg border border-dashed">
|
||||
<div className="bg-green-100 dark:bg-green-900 p-3 rounded-full mb-4">
|
||||
<Sprout className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-2">
|
||||
No {status === "all" ? "" : status} crops found
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
{status === "all"
|
||||
? "You haven't added any crops to this farm yet."
|
||||
: `No crops with status "${status}" found.`}
|
||||
</p>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add {status === "all" ? "your first" : "a new"} crop
|
||||
</Button>
|
||||
</div>
|
||||
) : activeFilter === status && filteredCrops.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
{filteredCrops.map((crop, index) => (
|
||||
<motion.div
|
||||
key={crop.uuid}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}>
|
||||
<CropCard
|
||||
crop={crop}
|
||||
onClick={() => router.push(`/farms/${farmId}/crops/${crop.uuid}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
@ -452,8 +391,16 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Crop Dialog */}
|
||||
<CropDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} onSubmit={handleAddCrop} />
|
||||
{/* ------------------------------
|
||||
Add Crop Dialog Component
|
||||
- Passes the mutation state to display loading indicators.
|
||||
------------------------------ */}
|
||||
<CropDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
onSubmit={handleAddCrop}
|
||||
isSubmitting={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,39 +7,77 @@ 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 { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Farm } from "@/types";
|
||||
import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing";
|
||||
|
||||
// ===================================================================
|
||||
// Schema Definition: Validates form inputs using Zod
|
||||
// See: https://zod.dev
|
||||
// ===================================================================
|
||||
const farmFormSchema = z.object({
|
||||
name: z.string().min(2, "Farm name must be at least 2 characters"),
|
||||
location: z.string().min(2, "Location 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(),
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Component Props Declaration
|
||||
// ===================================================================
|
||||
export interface AddFarmFormProps {
|
||||
onSubmit: (data: Partial<Farm>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Component: AddFarmForm
|
||||
// - Manages the creation of new farm records.
|
||||
// - Uses React Hook Form with Zod for form validation.
|
||||
// - Includes a map component for coordinate selection.
|
||||
// ===================================================================
|
||||
export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
// ---------------------------------------------------------------
|
||||
// State and Form Setup
|
||||
// ---------------------------------------------------------------
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof farmFormSchema>>({
|
||||
resolver: zodResolver(farmFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
location: "",
|
||||
latitude: 0, // Defaults handled by validation (marker must be selected)
|
||||
longitude: 0,
|
||||
type: "",
|
||||
area: "",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Form Submission Handler
|
||||
// - Converts form data to the expected Farm shape.
|
||||
// ---------------------------------------------------------------
|
||||
const handleSubmit = async (values: z.infer<typeof farmFormSchema>) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await onSubmit(values);
|
||||
const farmData: Partial<Farm> = {
|
||||
name: values.name,
|
||||
lat: values.latitude,
|
||||
lon: values.longitude,
|
||||
farmType: values.type,
|
||||
totalSize: values.area,
|
||||
};
|
||||
await onSubmit(farmData);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
console.error("Error submitting form:", error);
|
||||
@ -48,95 +86,184 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Map-to-Form Coordination: Update coordinates from the map
|
||||
// - Uses useCallback to preserve reference and optimize re-renders.
|
||||
// ---------------------------------------------------------------
|
||||
const handleShapeDrawn = useCallback(
|
||||
(data: ShapeData) => {
|
||||
// Log incoming shape data for debugging
|
||||
console.log("Shape drawn in form:", data);
|
||||
|
||||
// Only update the form if a single marker (i.e. point) is used
|
||||
if (data.type === "marker") {
|
||||
const { lat, lng } = data.position;
|
||||
form.setValue("latitude", lat, { shouldValidate: true });
|
||||
form.setValue("longitude", lng, { shouldValidate: true });
|
||||
console.log(`Set form lat: ${lat}, lng: ${lng}`);
|
||||
} else {
|
||||
// Note: Only markers update coordinates. Other shapes could be handled later.
|
||||
console.log(`Received shape type '${data.type}', but only 'marker' updates the form coordinates.`);
|
||||
}
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
// ===================================================================
|
||||
// Render: Split into two main sections - Form and Map
|
||||
// ===================================================================
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col lg:flex-row gap-6 p-4">
|
||||
{/* ==============================
|
||||
Start of Form Section
|
||||
============================== */}
|
||||
<div className="lg:flex-[1]">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
{/* 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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Location</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter farm location" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>City, region or specific address</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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
{/* 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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="area"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Total Area (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., 10 hectares" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>The total size of your farm</FormDescription>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} className="bg-green-600 hover:bg-green-700">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Farm"
|
||||
)}
|
||||
</Button>
|
||||
{/* 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-green-600 hover:bg-green-700">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Farm"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
{/* ==============================
|
||||
End of Form Section
|
||||
============================== */}
|
||||
|
||||
{/* ==============================
|
||||
Start of Map Section
|
||||
- Renders an interactive map for coordinate selection.
|
||||
============================== */}
|
||||
<div className="lg:flex-[2] min-h-[400px] lg:min-h-0 flex flex-col">
|
||||
<FormLabel>Farm Location (Draw marker on map)</FormLabel>
|
||||
<div className="mt-2 rounded-md overflow-hidden border flex-grow">
|
||||
<GoogleMapWithDrawing onShapeDrawn={handleShapeDrawn} />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<FormDescription className="mt-2">
|
||||
Select the marker tool above the map and click a location to set the latitude and longitude for your farm.
|
||||
Only markers will update the coordinates.
|
||||
</FormDescription>
|
||||
</div>
|
||||
{/* ==============================
|
||||
End of Map Section
|
||||
============================== */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import type { Farm } from "@/types";
|
||||
|
||||
export interface FarmCardProps {
|
||||
variant: "farm" | "add";
|
||||
farm?: Farm;
|
||||
farm?: Farm; // Use updated Farm type
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(farm.createdAt);
|
||||
}).format(new Date(farm.createdAt));
|
||||
|
||||
return (
|
||||
<Card className={cardClasses} onClick={onClick}>
|
||||
@ -49,7 +49,7 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200">
|
||||
{farm.type}
|
||||
{farm.farmType}
|
||||
</Badge>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3 w-3 mr-1" />
|
||||
@ -66,16 +66,16 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
|
||||
<h3 className="text-xl font-medium mb-1 truncate">{farm.name}</h3>
|
||||
<div className="flex items-center text-sm text-muted-foreground mb-2">
|
||||
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{farm.location}</span>
|
||||
<span className="truncate">{farm.lat}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">Area</p>
|
||||
<p className="font-medium">{farm.area}</p>
|
||||
<p className="font-medium">{farm.totalSize}</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">Crops</p>
|
||||
<p className="font-medium">{farm.crops}</p>
|
||||
<p className="font-medium">{farm.crops ? farm.crops.length : 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -36,44 +36,59 @@ export default function FarmSetupPage() {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data: farms,
|
||||
data: farms, // Type is Farm[] now
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<Farm[]>({
|
||||
// Use Farm[] type
|
||||
queryKey: ["farms"],
|
||||
queryFn: fetchFarms,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: Partial<Farm>) => createFarm(data),
|
||||
// Pass the correct type to createFarm
|
||||
mutationFn: (data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) =>
|
||||
createFarm(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["farms"] });
|
||||
setIsDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// export interface Farm {
|
||||
// CreatedAt: string;
|
||||
// FarmType: string;
|
||||
// Lat: number;
|
||||
// Lon: number;
|
||||
// Name: string;
|
||||
// OwnerID: string;
|
||||
// TotalSize: string;
|
||||
// UUID: string;
|
||||
// UpdatedAt: string;
|
||||
// }
|
||||
|
||||
const filteredAndSortedFarms = (farms || [])
|
||||
.filter(
|
||||
(farm) =>
|
||||
(activeFilter === "all" || farm.type === activeFilter) &&
|
||||
(farm.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
farm.location.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
farm.type.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
(activeFilter === "all" || farm.farmType === activeFilter) && // Use camelCase farmType
|
||||
(farm.name.toLowerCase().includes(searchQuery.toLowerCase()) || // Use camelCase name
|
||||
// farm.location is no longer a single string, use lat/lon if needed for search
|
||||
farm.farmType.toLowerCase().includes(searchQuery.toLowerCase())) // Use camelCase farmType
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (sortOrder === "newest") {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); // Use camelCase createdAt
|
||||
} else if (sortOrder === "oldest") {
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); // Use camelCase createdAt
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
return a.name.localeCompare(b.name); // Use camelCase name
|
||||
}
|
||||
});
|
||||
|
||||
// Get distinct farm types.
|
||||
const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.type))];
|
||||
const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.farmType))]; // Use camelCase farmType
|
||||
|
||||
const handleAddFarm = async (data: Partial<Farm>) => {
|
||||
await mutation.mutateAsync(data);
|
||||
@ -121,6 +136,7 @@ export default function FarmSetupPage() {
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{/* DropdownMenu remains the same, Check icon was missing */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
@ -188,7 +204,7 @@ export default function FarmSetupPage() {
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
You haven't added any farms yet. Get started by adding your first farm.
|
||||
You haven't added any farms yet. Get started by adding your first farm.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
@ -216,23 +232,17 @@ export default function FarmSetupPage() {
|
||||
{!isLoading && !isError && filteredAndSortedFarms.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="col-span-1">
|
||||
<motion.div /* ... */>
|
||||
<FarmCard variant="add" onClick={() => setIsDialogOpen(true)} />
|
||||
</motion.div>
|
||||
{filteredAndSortedFarms.map((farm, index) => (
|
||||
<motion.div
|
||||
key={farm.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
key={farm.uuid} // Use camelCase uuid initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="col-span-1">
|
||||
<FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.id}`)} />
|
||||
<FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.uuid}`)} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@ -248,6 +258,7 @@ export default function FarmSetupPage() {
|
||||
<DialogTitle>Add New Farm</DialogTitle>
|
||||
<DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Pass handleAddFarm (which now expects Partial<Farm>) */}
|
||||
<AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -4,7 +4,6 @@ import { useState } from "react";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
@ -18,7 +17,11 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -30,142 +33,242 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createInventoryItem } from "@/api/inventory";
|
||||
import type { CreateInventoryItemInput } from "@/types";
|
||||
import type {
|
||||
CreateInventoryItemInput,
|
||||
InventoryStatus,
|
||||
InventoryItemCategory,
|
||||
HarvestUnits,
|
||||
} from "@/types";
|
||||
|
||||
export function AddInventoryItem() {
|
||||
interface AddInventoryItemProps {
|
||||
inventoryCategory: InventoryItemCategory[];
|
||||
inventoryStatus: InventoryStatus[];
|
||||
harvestUnits: HarvestUnits[];
|
||||
}
|
||||
|
||||
export function AddInventoryItem({
|
||||
inventoryCategory,
|
||||
inventoryStatus,
|
||||
harvestUnits,
|
||||
}: AddInventoryItemProps) {
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [itemName, setItemName] = useState("");
|
||||
const [itemType, setItemType] = useState("");
|
||||
const [itemCategory, setItemCategory] = useState("");
|
||||
const [itemQuantity, setItemQuantity] = useState(0);
|
||||
const [itemUnit, setItemUnit] = useState("");
|
||||
|
||||
const [itemStatus, setItemStatus] = useState("");
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (item: CreateInventoryItemInput) => createInventoryItem(item),
|
||||
onSuccess: () => {
|
||||
// Invalidate queries to refresh inventory data.
|
||||
// invalidate queries to refresh inventory data
|
||||
queryClient.invalidateQueries({ queryKey: ["inventoryItems"] });
|
||||
// Reset form fields and close dialog.
|
||||
|
||||
setItemName("");
|
||||
setItemType("");
|
||||
setItemCategory("");
|
||||
setItemQuantity(0);
|
||||
setItemUnit("");
|
||||
setDate(undefined);
|
||||
setOpen(false);
|
||||
setIsSubmitted(true);
|
||||
setSuccessMessage("Item created successfully!");
|
||||
|
||||
// reset success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setIsSubmitted(false);
|
||||
setSuccessMessage("");
|
||||
setOpen(false);
|
||||
}, 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Error creating item: ", error);
|
||||
setErrorMessage(
|
||||
"There was an error creating the item. Please try again."
|
||||
);
|
||||
|
||||
// reset success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setErrorMessage("");
|
||||
}, 3000);
|
||||
},
|
||||
});
|
||||
const inputStates = [itemName, itemCategory, itemUnit, itemStatus, date];
|
||||
const isInputValid = inputStates.every((input) => input);
|
||||
|
||||
const handleSave = () => {
|
||||
// Basic validation (you can extend this as needed)
|
||||
if (!itemName || !itemType || !itemCategory || !itemUnit) return;
|
||||
mutation.mutate({
|
||||
if (!isInputValid) {
|
||||
setErrorMessage(
|
||||
"There was an error creating the item. Please try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: CreateInventoryItemInput = {
|
||||
name: itemName,
|
||||
type: itemType,
|
||||
category: itemCategory,
|
||||
categoryId:
|
||||
inventoryCategory.find((item) => item.name === itemCategory)?.id || 0,
|
||||
quantity: itemQuantity,
|
||||
unit: itemUnit,
|
||||
});
|
||||
unitId: harvestUnits.find((item) => item.name === itemUnit)?.id || 0,
|
||||
statusId:
|
||||
inventoryStatus.find((item) => item.name === itemStatus)?.id || 0,
|
||||
dateAdded: date ? date.toISOString() : new Date().toISOString(),
|
||||
};
|
||||
// console.table(newItem);
|
||||
mutation.mutate(newItem);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Add New Item</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Inventory Item</DialogTitle>
|
||||
<DialogDescription>Add a new plantation or fertilizer item to your inventory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input id="name" className="col-span-3" value={itemName} onChange={(e) => setItemName(e.target.value)} />
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Add New Item</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Inventory Item</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new plantation or fertilizer item to your inventory.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
className="col-span-3"
|
||||
value={itemName}
|
||||
onChange={(e) => setItemName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
Category
|
||||
</Label>
|
||||
<Select value={itemCategory} onValueChange={setItemCategory}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Category</SelectLabel>
|
||||
{inventoryCategory.map((categoryItem) => (
|
||||
<SelectItem
|
||||
key={categoryItem.id}
|
||||
value={categoryItem.name}
|
||||
>
|
||||
{categoryItem.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
Status
|
||||
</Label>
|
||||
<Select value={itemStatus} onValueChange={setItemStatus}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Status</SelectLabel>
|
||||
{inventoryStatus.map((statusItem) => (
|
||||
<SelectItem key={statusItem.id} value={statusItem.name}>
|
||||
{statusItem.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="quantity" className="text-right">
|
||||
Quantity
|
||||
</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
className="col-span-3"
|
||||
value={itemQuantity === 0 ? "" : itemQuantity}
|
||||
onChange={(e) => setItemQuantity(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
Unit
|
||||
</Label>
|
||||
<Select value={itemUnit} onValueChange={setItemUnit}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Unit</SelectLabel>
|
||||
{harvestUnits.map((unit) => (
|
||||
<SelectItem key={unit.id} value={unit.name}>
|
||||
{unit.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="date" className="text-right">
|
||||
Date
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"col-span-3 justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP") : "Pick a date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
Type
|
||||
</Label>
|
||||
<Select value={itemType} onValueChange={setItemType}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Type</SelectLabel>
|
||||
<SelectItem value="plantation">Plantation</SelectItem>
|
||||
<SelectItem value="fertilizer">Fertilizer</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="category" className="text-right">
|
||||
Category
|
||||
</Label>
|
||||
<Input
|
||||
id="category"
|
||||
className="col-span-3"
|
||||
placeholder="e.g., Seeds, Organic"
|
||||
value={itemCategory}
|
||||
onChange={(e) => setItemCategory(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="quantity" className="text-right">
|
||||
Quantity
|
||||
</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
className="col-span-3"
|
||||
value={itemQuantity}
|
||||
onChange={(e) => setItemQuantity(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="unit" className="text-right">
|
||||
Unit
|
||||
</Label>
|
||||
<Input
|
||||
id="unit"
|
||||
className="col-span-3"
|
||||
placeholder="e.g., kg, packets"
|
||||
value={itemUnit}
|
||||
onChange={(e) => setItemUnit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="date" className="text-right">
|
||||
Date
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn("col-span-3 justify-start text-left font-normal", !date && "text-muted-foreground")}>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP") : "Pick a date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar mode="single" selected={date} onSelect={setDate} initialFocus />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={handleSave}>
|
||||
Save Item
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogFooter>
|
||||
<div className="flex flex-col items-center w-full space-y-2">
|
||||
<Button type="button" onClick={handleSave} className="w-full">
|
||||
Save Item
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
{isSubmitted && (
|
||||
<p className="text-green-500 text-sm">
|
||||
{successMessage} You may close this window.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,63 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
// import { deleteInventoryItem } from "@/api/inventory";
|
||||
// import type { DeleteInventoryItemInput } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { deleteInventoryItem } from "@/api/inventory";
|
||||
|
||||
export function DeleteInventoryItem() {
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
export function DeleteInventoryItem({ id }: { id: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [itemName, setItemName] = useState("");
|
||||
const [itemType, setItemType] = useState("");
|
||||
const [itemCategory, setItemCategory] = useState("");
|
||||
const [itemQuantity, setItemQuantity] = useState(0);
|
||||
const [itemUnit, setItemUnit] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// const queryClient = useQueryClient();
|
||||
const { mutate: deleteItem, status } = useMutation({
|
||||
mutationFn: deleteInventoryItem,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["inventoryItems"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to delete item:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
// handle delete item
|
||||
deleteItem(id.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-red-500 hover:bg-red-800 text-white"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete Item
|
||||
</Button>
|
||||
<div>
|
||||
{/* delete confirmation dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-red-500 hover:bg-red-800 text-white"
|
||||
>
|
||||
Delete Item
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this item? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="bg-gray-500 hover:bg-gray-700 text-white"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red-600 hover:bg-red-800 text-white"
|
||||
onClick={handleDelete}
|
||||
disabled={status === "pending"}
|
||||
>
|
||||
{status === "pending" ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -18,11 +14,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -32,65 +23,93 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
// import { updateInventoryItem } from "@/api/inventory";
|
||||
// import type { UpdateInventoryItemInput } from "@/types";
|
||||
|
||||
export interface EditInventoryItemProps {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
}
|
||||
import {
|
||||
InventoryStatus,
|
||||
InventoryItemCategory,
|
||||
HarvestUnits,
|
||||
UpdateInventoryItemInput,
|
||||
EditInventoryItemInput,
|
||||
} from "@/types";
|
||||
import { updateInventoryItem } from "@/api/inventory";
|
||||
|
||||
export function EditInventoryItem({
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
status,
|
||||
type,
|
||||
unit,
|
||||
quantity,
|
||||
}: EditInventoryItemProps) {
|
||||
item,
|
||||
fetchedInventoryStatus,
|
||||
fetchedInventoryCategory,
|
||||
fetchedHarvestUnits,
|
||||
}: {
|
||||
item: UpdateInventoryItemInput;
|
||||
fetchedInventoryStatus: InventoryStatus[];
|
||||
fetchedInventoryCategory: InventoryItemCategory[];
|
||||
fetchedHarvestUnits: HarvestUnits[];
|
||||
}) {
|
||||
// console.table(item);
|
||||
// console.log(item.id);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [itemName, setItemName] = useState(name);
|
||||
const [itemType, setItemType] = useState(type);
|
||||
const [itemCategory, setItemCategory] = useState(category);
|
||||
const [itemQuantity, setItemQuantity] = useState(quantity);
|
||||
const [itemUnit, setItemUnit] = useState(unit);
|
||||
const [itemStatus, setItemStatus] = useState(status);
|
||||
const [itemName, setItemName] = useState(item.name);
|
||||
const [itemCategory, setItemCategory] = useState(
|
||||
fetchedInventoryCategory.find((x) => x.id === item.categoryId)?.name
|
||||
);
|
||||
|
||||
// const queryClient = useQueryClient();
|
||||
const [itemQuantity, setItemQuantity] = useState(item.quantity);
|
||||
|
||||
// const mutation = useMutation({
|
||||
// mutationFn: (item: UpdateInventoryItemInput) => UpdateInventoryItem(item),
|
||||
// onSuccess: () => {
|
||||
// // Invalidate queries to refresh inventory data.
|
||||
// queryClient.invalidateQueries({ queryKey: ["inventoryItems"] });
|
||||
// // Reset form fields and close dialog.
|
||||
// setItemName("");
|
||||
// setItemType("");
|
||||
// setItemCategory("");
|
||||
// setItemQuantity(0);
|
||||
// setItemUnit("");
|
||||
// setDate(undefined);
|
||||
// setOpen(false);
|
||||
// },
|
||||
// });
|
||||
const [itemUnit, setItemUnit] = useState(
|
||||
fetchedHarvestUnits.find((x) => x.id === item.unitId)?.name
|
||||
);
|
||||
|
||||
const [itemStatus, setItemStatus] = useState(
|
||||
fetchedInventoryStatus.find((x) => x.id === item.statusId)?.name
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (x: EditInventoryItemInput) => updateInventoryItem(item.id, x),
|
||||
onSuccess: () => {
|
||||
// invalidate queries to refresh inventory data.
|
||||
queryClient.invalidateQueries({ queryKey: ["inventoryItems"] });
|
||||
// reset form fields and close dialog.
|
||||
setItemName("");
|
||||
setItemCategory("");
|
||||
setItemQuantity(0);
|
||||
setItemUnit("");
|
||||
setOpen(false);
|
||||
setItemStatus("");
|
||||
},
|
||||
});
|
||||
|
||||
// send edit request
|
||||
const handleEdit = () => {
|
||||
// // Basic validation (you can extend this as needed)
|
||||
// if (!itemName || !itemType || !itemCategory || !itemUnit) return;
|
||||
// mutation.mutate({
|
||||
// name: itemName,
|
||||
// type: itemType,
|
||||
// category: itemCategory,
|
||||
// quantity: itemQuantity,
|
||||
// unit: itemUnit,
|
||||
// });
|
||||
if (!itemName || !itemCategory || !itemUnit) {
|
||||
setError("All fields are required. Please fill in missing details.");
|
||||
return;
|
||||
}
|
||||
|
||||
const category = fetchedInventoryCategory.find(
|
||||
(c) => c.name === itemCategory
|
||||
)?.id;
|
||||
const unit = fetchedHarvestUnits.find((u) => u.name === itemUnit)?.id;
|
||||
const status = fetchedInventoryStatus.find(
|
||||
(s) => s.name === itemStatus
|
||||
)?.id;
|
||||
|
||||
if (!category || !unit || !status) {
|
||||
setError(
|
||||
"Invalid category, unit, or status. Please select a valid option."
|
||||
);
|
||||
return;
|
||||
}
|
||||
// console.log("Mutate called");
|
||||
// console.log(item.id);
|
||||
mutation.mutate({
|
||||
name: itemName,
|
||||
categoryId: category,
|
||||
quantity: itemQuantity ?? 0,
|
||||
unitId: unit,
|
||||
statusId: status,
|
||||
dateAdded: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@ -119,17 +138,20 @@ export function EditInventoryItem({
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
Type
|
||||
Category
|
||||
</Label>
|
||||
<Select value={itemType.toLowerCase()} onValueChange={setItemType}>
|
||||
<Select value={itemCategory} onValueChange={setItemCategory}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Type</SelectLabel>
|
||||
<SelectItem value="plantation">Plantation</SelectItem>
|
||||
<SelectItem value="fertilizer">Fertilizer</SelectItem>
|
||||
<SelectLabel>Category</SelectLabel>
|
||||
{fetchedInventoryCategory.map((categoryItem, _) => (
|
||||
<SelectItem key={categoryItem.id} value={categoryItem.name}>
|
||||
{categoryItem.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@ -138,35 +160,22 @@ export function EditInventoryItem({
|
||||
<Label htmlFor="type" className="text-right">
|
||||
Status
|
||||
</Label>
|
||||
<Select
|
||||
value={itemStatus.toLowerCase()}
|
||||
onValueChange={setItemStatus}
|
||||
>
|
||||
<Select value={itemStatus} onValueChange={setItemStatus}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Status</SelectLabel>
|
||||
<SelectItem value="in stock">In Stock</SelectItem>
|
||||
<SelectItem value="low stock">Low Stock</SelectItem>
|
||||
<SelectItem value="out of stock">Out Of Stock</SelectItem>
|
||||
{fetchedInventoryStatus.map((statusItem, _) => (
|
||||
<SelectItem key={statusItem.id} value={statusItem.name}>
|
||||
{statusItem.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="category" className="text-right">
|
||||
Category
|
||||
</Label>
|
||||
<Input
|
||||
id="category"
|
||||
className="col-span-3"
|
||||
placeholder="e.g., Seeds, Organic"
|
||||
value={itemCategory}
|
||||
onChange={(e) => setItemCategory(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="quantity" className="text-right">
|
||||
Quantity
|
||||
@ -180,21 +189,30 @@ export function EditInventoryItem({
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="unit" className="text-right">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
Unit
|
||||
</Label>
|
||||
<Input
|
||||
id="unit"
|
||||
className="col-span-3"
|
||||
placeholder="e.g., kg, packets"
|
||||
value={itemUnit}
|
||||
onChange={(e) => setItemUnit(e.target.value)}
|
||||
/>
|
||||
<Select value={itemUnit} onValueChange={setItemUnit}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Unit</SelectLabel>
|
||||
{fetchedHarvestUnits.map((unit, _) => (
|
||||
<SelectItem key={unit.id} value={unit.name}>
|
||||
{unit.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
<Button type="submit" onClick={handleEdit}>
|
||||
Edit Item
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -22,22 +22,27 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TriangleAlertIcon } from "lucide-react";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Search } from "lucide-react";
|
||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||
import { fetchHarvestUnits } from "@/api/harvest";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { fetchInventoryItems, fetchInventoryStatus } from "@/api/inventory";
|
||||
import { AddInventoryItem } from "./add-inventory-item";
|
||||
import {
|
||||
EditInventoryItem,
|
||||
EditInventoryItemProps,
|
||||
} from "./edit-inventory-item";
|
||||
fetchInventoryItems,
|
||||
fetchInventoryStatus,
|
||||
fetchInventoryCategory,
|
||||
} from "@/api/inventory";
|
||||
import { AddInventoryItem } from "./add-inventory-item";
|
||||
import { EditInventoryItem } from "./edit-inventory-item";
|
||||
import { DeleteInventoryItem } from "./delete-inventory-item";
|
||||
import { InventoryItem } from "@/types";
|
||||
|
||||
export default function InventoryPage() {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@ -45,7 +50,8 @@ export default function InventoryPage() {
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
//////////////////////////////
|
||||
// query the necessary data for edit and etc.
|
||||
const {
|
||||
data: inventoryItems = [],
|
||||
isLoading: isItemLoading,
|
||||
@ -55,6 +61,8 @@ export default function InventoryPage() {
|
||||
queryFn: fetchInventoryItems,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
// console.table(inventoryItems);
|
||||
// console.log(inventoryItems);
|
||||
|
||||
const {
|
||||
data: inventoryStatus = [],
|
||||
@ -65,38 +73,98 @@ export default function InventoryPage() {
|
||||
queryFn: fetchInventoryStatus,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// console.log(inventoryStatus);
|
||||
const {
|
||||
data: inventoryCategory = [],
|
||||
isLoading: isLoadingCategory,
|
||||
isError: isErrorCategory,
|
||||
} = useQuery({
|
||||
queryKey: ["inventoryCategory"],
|
||||
queryFn: fetchInventoryCategory,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const {
|
||||
data: harvestUnits = [],
|
||||
isLoading: isLoadingHarvestUnits,
|
||||
isError: isErrorHarvestUnits,
|
||||
} = useQuery({
|
||||
queryKey: ["harvestUnits"],
|
||||
queryFn: fetchHarvestUnits,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
//////////////////////////////
|
||||
// console.table(inventoryItems);
|
||||
console.table(inventoryStatus);
|
||||
// console.table(inventoryStatus);
|
||||
// console.table(harvestUnits);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const filteredItems = useMemo(() => {
|
||||
return inventoryItems
|
||||
.map((item) => ({
|
||||
...item,
|
||||
id: String(item.id), // Convert `id` to string here
|
||||
status: { id: item.status.id, name: item.status.name },
|
||||
category: { id: item.category.id, name: item.category.name },
|
||||
unit: { id: item.unit.id, name: item.unit.name },
|
||||
fetchedInventoryStatus: inventoryStatus,
|
||||
fetchedInventoryCategory: inventoryCategory,
|
||||
fetchedHarvestUnits: harvestUnits,
|
||||
lastUpdated: new Date(item.updatedAt).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}),
|
||||
}))
|
||||
.filter((item) =>
|
||||
item.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [inventoryItems, searchTerm]);
|
||||
|
||||
// prepare columns for table
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: "name", header: "Name" },
|
||||
{ accessorKey: "category", header: "Category" },
|
||||
{ accessorKey: "quantity", header: "Quantity" },
|
||||
{ accessorKey: "unit", header: "Unit" },
|
||||
{ accessorKey: "lastUpdated", header: "Last Updated" },
|
||||
{
|
||||
accessorKey: "category",
|
||||
header: "Category",
|
||||
cell: ({ row }: { row: { original: InventoryItem } }) =>
|
||||
row.original.category.name,
|
||||
},
|
||||
{
|
||||
accessorKey: "quantity",
|
||||
header: "Quantity",
|
||||
},
|
||||
{
|
||||
accessorKey: "unit",
|
||||
header: "Unit",
|
||||
cell: ({ row }: { row: { original: InventoryItem } }) =>
|
||||
row.original.unit.name,
|
||||
},
|
||||
{
|
||||
accessorKey: "lastUpdated",
|
||||
header: "Last Updated",
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: (info: { getValue: () => string }) => {
|
||||
const status = info.getValue();
|
||||
cell: ({ row }: { row: { original: InventoryItem } }) => {
|
||||
const status = row.original.status.name;
|
||||
|
||||
let statusClass = ""; // default status class
|
||||
let statusClass = "";
|
||||
|
||||
if (status === "Low Stock") {
|
||||
statusClass = "bg-yellow-300"; // yellow for low stock
|
||||
} else if (status === "Out Of Stock") {
|
||||
statusClass = "bg-red-500 text-white"; // red for out of stock
|
||||
if (status === "In Stock") {
|
||||
statusClass = "bg-green-500 hover:bg-green-600 text-white";
|
||||
} else if (status === "Low Stock") {
|
||||
statusClass = "bg-yellow-300 hover:bg-yellow-400";
|
||||
} else if (status === "Out of Stock") {
|
||||
statusClass = "bg-red-500 hover:bg-red-600 text-white";
|
||||
} else if (status === "Expired") {
|
||||
statusClass = "bg-gray-500 hover:bg-gray-600 text-white";
|
||||
} else if (status === "Reserved") {
|
||||
statusClass = "bg-blue-500 hover:bg-blue-600 text-white";
|
||||
}
|
||||
|
||||
return (
|
||||
@ -109,15 +177,30 @@ export default function InventoryPage() {
|
||||
{
|
||||
accessorKey: "edit",
|
||||
header: "Edit",
|
||||
cell: ({ row }: { row: { original: EditInventoryItemProps } }) => (
|
||||
<EditInventoryItem {...row.original} />
|
||||
cell: ({ row }: { row: { original: InventoryItem } }) => (
|
||||
<EditInventoryItem
|
||||
item={{
|
||||
id: row.original.id,
|
||||
name: row.original.name,
|
||||
categoryId: row.original.category.id,
|
||||
quantity: row.original.quantity,
|
||||
unitId: row.original.unit.id,
|
||||
dateAdded: row.original.dateAdded,
|
||||
statusId: row.original.status.id,
|
||||
}}
|
||||
fetchedInventoryStatus={inventoryStatus}
|
||||
fetchedInventoryCategory={inventoryCategory}
|
||||
fetchedHarvestUnits={harvestUnits}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "delete",
|
||||
header: "Delete",
|
||||
cell: () => <DeleteInventoryItem />,
|
||||
cell: ({ row }: { row: { original: InventoryItem } }) => (
|
||||
<DeleteInventoryItem id={row.original.id} />
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
@ -132,20 +215,61 @@ export default function InventoryPage() {
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
});
|
||||
const loadingStates = [
|
||||
isItemLoading,
|
||||
isLoadingStatus,
|
||||
isLoadingCategory,
|
||||
isLoadingHarvestUnits,
|
||||
];
|
||||
const errorStates = [
|
||||
isItemError,
|
||||
isErrorStatus,
|
||||
isErrorCategory,
|
||||
isErrorHarvestUnits,
|
||||
];
|
||||
|
||||
if (isItemLoading || isLoadingStatus)
|
||||
const isLoading = loadingStates.some((loading) => loading);
|
||||
const isError = errorStates.some((error) => error);
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
if (isItemError || isErrorStatus)
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
Error loading inventory data.
|
||||
</div>
|
||||
);
|
||||
|
||||
if (inventoryItems.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh]">
|
||||
<Alert variant="destructive" className="w-full max-w-md text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<TriangleAlertIcon className="h-6 w-6 text-red-500 mb-2" />
|
||||
<AlertTitle>No Inventory Data</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div>
|
||||
You currently have no inventory items. Add a new item to get
|
||||
started!
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<AddInventoryItem
|
||||
inventoryCategory={inventoryCategory}
|
||||
inventoryStatus={inventoryStatus}
|
||||
harvestUnits={harvestUnits}
|
||||
/>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<div className="flex-1 flex flex-col">
|
||||
@ -159,7 +283,11 @@ export default function InventoryPage() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<AddInventoryItem />
|
||||
<AddInventoryItem
|
||||
inventoryCategory={inventoryCategory}
|
||||
inventoryStatus={inventoryStatus}
|
||||
harvestUnits={harvestUnits}
|
||||
/>
|
||||
</div>
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
|
||||
@ -7,6 +7,9 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
|
||||
import DynamicBreadcrumb from "./dynamic-breadcrumb";
|
||||
import { extractRoute } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { APIProvider } from "@vis.gl/react-google-maps";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
@ -15,21 +18,27 @@ export default function AppLayout({
|
||||
}>) {
|
||||
const pathname = usePathname();
|
||||
const currentPathname = extractRoute(pathname);
|
||||
const form = useForm();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<ThemeToggle />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<DynamicBreadcrumb pathname={currentPathname} />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<APIProvider apiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!}>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<FormProvider {...form}>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<ThemeToggle />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<DynamicBreadcrumb pathname={currentPathname} />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
<Toaster />
|
||||
</FormProvider>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</APIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -294,7 +294,12 @@ export default function HarvestDetailsForm({
|
||||
)}
|
||||
/>
|
||||
<div className="col-span-3 flex justify-center">
|
||||
<Button type="submit">Save</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-blue-500 hover:bg-blue-600 duration-100"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -1,123 +1,140 @@
|
||||
"use client";
|
||||
import { SetStateAction, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import PlantingDetailsForm from "./planting-detail-form";
|
||||
import HarvestDetailsForm from "./harvest-detail-form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
plantingDetailsFormSchema,
|
||||
harvestDetailsFormSchema,
|
||||
} from "@/schemas/application.schema";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type plantingSchema = z.infer<typeof plantingDetailsFormSchema>;
|
||||
type harvestSchema = z.infer<typeof harvestDetailsFormSchema>;
|
||||
|
||||
type PlantingSchema = z.infer<typeof plantingDetailsFormSchema>;
|
||||
type HarvestSchema = z.infer<typeof harvestDetailsFormSchema>;
|
||||
|
||||
const steps = [
|
||||
{ title: "Step 1", description: "Planting Details" },
|
||||
{ title: "Step 2", description: "Harvest Details" },
|
||||
{ title: "Step 3", description: "Select Map Area" },
|
||||
];
|
||||
|
||||
export default function SetupPage() {
|
||||
const [plantingDetails, setPlantingDetails] = useState<plantingSchema | null>(
|
||||
const [step, setStep] = useState(1);
|
||||
const [plantingDetails, setPlantingDetails] = useState<PlantingSchema | null>(
|
||||
null
|
||||
);
|
||||
const [harvestDetails, setHarvestDetails] = useState<harvestSchema | null>(
|
||||
const [harvestDetails, setHarvestDetails] = useState<HarvestSchema | null>(
|
||||
null
|
||||
);
|
||||
const [mapData, setMapData] = useState<{ lat: number; lng: number }[] | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// handle planting details submission
|
||||
const handlePlantingDetailsChange = (data: plantingSchema) => {
|
||||
setPlantingDetails(data);
|
||||
const handleNext = () => {
|
||||
if (step === 1 && !plantingDetails) {
|
||||
toast.warning(
|
||||
"Please complete the Planting Details before proceeding.",
|
||||
{
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => toast.dismiss(),
|
||||
},
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (step === 2 && !harvestDetails) {
|
||||
toast.warning(
|
||||
"Please complete the Harvest Details before proceeding.",
|
||||
{
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => toast.dismiss(),
|
||||
},
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
setStep((prev) => prev + 1);
|
||||
};
|
||||
|
||||
// handle harvest details submission
|
||||
const handleHarvestDetailsChange = (data: harvestSchema) => {
|
||||
setHarvestDetails(data);
|
||||
const handleBack = () => {
|
||||
setStep((prev) => prev - 1);
|
||||
};
|
||||
|
||||
// handle map area selection
|
||||
const handleMapDataChange = (data: { lat: number; lng: number }[]) => {
|
||||
setMapData((prevMapData) => {
|
||||
if (prevMapData) {
|
||||
return [...prevMapData, ...data];
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// log the changes
|
||||
useEffect(() => {
|
||||
// console.log(plantingDetails);
|
||||
// console.log(harvestDetails);
|
||||
console.table(mapData);
|
||||
}, [plantingDetails, harvestDetails, mapData]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!plantingDetails || !harvestDetails || !mapData) {
|
||||
alert("Please complete all sections before submitting.");
|
||||
if (!mapData) {
|
||||
toast.warning("Please select an area on the map before submitting.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => toast.dismiss(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
plantingDetails,
|
||||
harvestDetails,
|
||||
mapData,
|
||||
};
|
||||
console.log("Submitting:", { plantingDetails, harvestDetails, mapData });
|
||||
|
||||
console.log("Form data to be submitted:", formData);
|
||||
// send request to the server
|
||||
|
||||
fetch("/api/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
console.log("Response from backend:", data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error submitting form:", error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
{/* Planting Details Section */}
|
||||
<div className="flex justify-center">
|
||||
<h1 className="text-2xl">Planting Details</h1>
|
||||
</div>
|
||||
<Separator className="mt-3" />
|
||||
<div className="mt-10 flex justify-center">
|
||||
<PlantingDetailsForm onChange={handlePlantingDetailsChange} />
|
||||
{/* Stepper Navigation */}
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
{steps.map((item, index) => (
|
||||
<div key={index} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-full text-white font-bold ${
|
||||
step === index + 1 ? "bg-blue-500" : "bg-gray-500"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="font-medium mt-2">{item.title}</span>
|
||||
<span className="text-gray-500 text-sm">{item.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Harvest Details Section */}
|
||||
<div className="flex justify-center mt-20">
|
||||
<h1 className="text-2xl">Harvest Details</h1>
|
||||
</div>
|
||||
<Separator className="mt-3" />
|
||||
<div className="mt-10 flex justify-center">
|
||||
<HarvestDetailsForm onChange={handleHarvestDetailsChange} />
|
||||
</div>
|
||||
<Separator className="mb-5" />
|
||||
|
||||
{/* Map Section */}
|
||||
<div className="mt-10">
|
||||
<div className="flex justify-center mt-20">
|
||||
<h1 className="text-2xl">Map</h1>
|
||||
</div>
|
||||
<Separator className="mt-3" />
|
||||
<div className="mt-10">
|
||||
<GoogleMapWithDrawing onAreaSelected={handleMapDataChange} />
|
||||
</div>
|
||||
</div>
|
||||
{step === 1 && (
|
||||
<>
|
||||
<h2 className="text-xl text-center mb-5">Planting Details</h2>
|
||||
<PlantingDetailsForm onChange={setPlantingDetails} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="mt-10 flex justify-center">
|
||||
<button onClick={handleSubmit} className="btn btn-primary">
|
||||
Submit All Data
|
||||
</button>
|
||||
{step === 2 && (
|
||||
<>
|
||||
<h2 className="text-xl text-center mb-5">Harvest Details</h2>
|
||||
<HarvestDetailsForm onChange={setHarvestDetails} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
<h2 className="text-xl text-center mb-5">Select Area on Map</h2>
|
||||
<GoogleMapWithDrawing onAreaSelected={setMapData} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-10 flex justify-between">
|
||||
<Button onClick={handleBack} disabled={step === 1}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{step < 3 ? (
|
||||
<Button onClick={handleNext}>Next</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit}>Submit</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -23,14 +23,16 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRef } from "react";
|
||||
|
||||
type plantingSchema = z.infer<typeof plantingDetailsFormSchema>;
|
||||
|
||||
export default function PlantingDetailsForm({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (data: plantingSchema) => void;
|
||||
onChange: (data: plantingSchema) => void;
|
||||
}) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(plantingDetailsFormSchema),
|
||||
defaultValues: {
|
||||
@ -57,6 +59,7 @@ export default function PlantingDetailsForm({
|
||||
<form
|
||||
className="grid grid-cols-3 gap-5"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
ref={formRef}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -369,7 +372,9 @@ export default function PlantingDetailsForm({
|
||||
)}
|
||||
/>
|
||||
<div className="col-span-3 flex justify-center">
|
||||
<Button type="submit">Save</Button>
|
||||
<Button type="submit" className="bg-blue-500 hover:bg-blue-600 duration-100">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function ForgotPasswordModal() {
|
||||
|
||||
@ -22,7 +22,7 @@ export function GoogleSigninButton() {
|
||||
const exchangeRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/oauth/exchange`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ access_token: credentialResponse.credential }),
|
||||
body: JSON.stringify({ accessToken: credentialResponse.credential }),
|
||||
});
|
||||
if (!exchangeRes.ok) {
|
||||
throw new Error("Exchange token request failed");
|
||||
|
||||
@ -18,7 +18,6 @@ import { loginUser } from "@/api/authentication";
|
||||
import { SessionContext } from "@/context/SessionContext";
|
||||
import { Eye, EyeOff, Leaf, ArrowRight, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
export default function SigninPage() {
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
@ -14,9 +14,18 @@ import type { z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { registerUser } from "@/api/authentication";
|
||||
import { SessionContext } from "@/context/SessionContext";
|
||||
import { Eye, EyeOff, Leaf, ArrowRight, AlertCircle, Loader2, Check } from "lucide-react";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Leaf,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { GoogleSigninButton } from "../signin/google-oauth";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
@ -75,7 +84,9 @@ export default function SignupPage() {
|
||||
const data = await registerUser(values.email, values.password);
|
||||
|
||||
if (!data) {
|
||||
setServerError("An error occurred while registering. Please try again.");
|
||||
setServerError(
|
||||
"An error occurred while registering. Please try again."
|
||||
);
|
||||
throw new Error("No data received from the server.");
|
||||
}
|
||||
|
||||
@ -120,9 +131,12 @@ export default function SignupPage() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">Join the farming revolution</h2>
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Join the farming revolution
|
||||
</h2>
|
||||
<p className="text-green-100 mb-6">
|
||||
Create your account today and discover how ForFarm can help you optimize your agricultural operations.
|
||||
Create your account today and discover how ForFarm can help
|
||||
you optimize your agricultural operations.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
@ -148,11 +162,18 @@ export default function SignupPage() {
|
||||
<div className="flex justify-center items-center p-6">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Theme Selector Placeholder */}
|
||||
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">Theme Selector Placeholder</div>
|
||||
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Theme Selector Placeholder
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden flex justify-center mb-8">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image src="/forfarm-logo.png" alt="Forfarm" width={80} height={80} />
|
||||
<Image
|
||||
src="/forfarm-logo.png"
|
||||
alt="Forfarm"
|
||||
width={80}
|
||||
height={80}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -160,7 +181,10 @@ export default function SignupPage() {
|
||||
<h1 className="text-3xl font-bold mb-2">Create your account</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Already have an account?{" "}
|
||||
<Link href="/auth/signin" className="text-green-600 hover:text-green-700 font-medium">
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="text-green-600 hover:text-green-700 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
@ -184,7 +208,10 @@ export default function SignupPage() {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium dark:text-gray-300">
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className="text-sm font-medium dark:text-gray-300"
|
||||
>
|
||||
Email
|
||||
</Label>
|
||||
<div className="relative">
|
||||
@ -192,7 +219,11 @@ export default function SignupPage() {
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder="name@example.com"
|
||||
className={`h-12 px-4 ${errors.email ? "border-red-500 focus-visible:ring-red-500" : ""}`}
|
||||
className={`h-12 px-4 ${
|
||||
errors.email
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}`}
|
||||
{...register("email")}
|
||||
/>
|
||||
</div>
|
||||
@ -206,7 +237,10 @@ export default function SignupPage() {
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium dark:text-gray-300">
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium dark:text-gray-300"
|
||||
>
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
@ -214,15 +248,26 @@ export default function SignupPage() {
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="password"
|
||||
placeholder="••••••••"
|
||||
className={`h-12 px-4 ${errors.password ? "border-red-500 focus-visible:ring-red-500" : ""}`}
|
||||
className={`h-12 px-4 ${
|
||||
errors.password
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}`}
|
||||
{...register("password", { onChange: onPasswordChange })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
aria-label={
|
||||
showPassword ? "Hide password" : "Show password"
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowPassword(!showPassword)}>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -230,7 +275,9 @@ export default function SignupPage() {
|
||||
{password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Password strength</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Password strength
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
passwordStrength <= 25
|
||||
@ -240,11 +287,15 @@ export default function SignupPage() {
|
||||
: passwordStrength <= 75
|
||||
? "text-blue-500"
|
||||
: "text-green-500"
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
{getPasswordStrengthText()}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={passwordStrength} className={`${getPasswordStrengthColor()} h-1`} />
|
||||
<Progress
|
||||
value={passwordStrength}
|
||||
className={`${getPasswordStrengthColor()} h-1`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -258,7 +309,10 @@ export default function SignupPage() {
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-sm font-medium dark:text-gray-300">
|
||||
<Label
|
||||
htmlFor="confirmPassword"
|
||||
className="text-sm font-medium dark:text-gray-300"
|
||||
>
|
||||
Confirm Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
@ -266,15 +320,26 @@ export default function SignupPage() {
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
id="confirmPassword"
|
||||
placeholder="••••••••"
|
||||
className={`h-12 px-4 ${errors.confirmPassword ? "border-red-500 focus-visible:ring-red-500" : ""}`}
|
||||
className={`h-12 px-4 ${
|
||||
errors.confirmPassword
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}`}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
|
||||
aria-label={
|
||||
showConfirmPassword ? "Hide password" : "Show password"
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}>
|
||||
{showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
@ -288,7 +353,8 @@ export default function SignupPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 rounded-full font-medium text-base bg-green-600 hover:bg-green-700 transition-all"
|
||||
disabled={isLoading}>
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@ -316,22 +382,23 @@ export default function SignupPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-12 rounded-full border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<Image src="/google-logo.png" alt="Google Logo" width={20} height={20} className="mr-2" />
|
||||
Sign up with Google
|
||||
</Button>
|
||||
<GoogleSigninButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-8">
|
||||
By signing up, you agree to our{" "}
|
||||
<Link href="/terms" className="text-green-600 hover:text-green-700">
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/privacy" className="text-green-600 hover:text-green-700">
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@ -1,116 +1,51 @@
|
||||
import { GoogleMap, LoadScript, DrawingManager } from "@react-google-maps/api";
|
||||
import { useState, useCallback } from "react";
|
||||
// google-map-with-drawing.tsx
|
||||
import React from "react";
|
||||
import { ControlPosition, Map, MapControl } from "@vis.gl/react-google-maps";
|
||||
|
||||
const containerStyle = {
|
||||
width: "100%",
|
||||
height: "500px",
|
||||
};
|
||||
import { UndoRedoControl } from "@/components/map-component/undo-redo-control";
|
||||
// Import ShapeData and useDrawingManager from the correct path
|
||||
import { useDrawingManager, type ShapeData } from "@/components/map-component/use-drawing-manager"; // Adjust path if needed
|
||||
|
||||
const center = { lat: 13.7563, lng: 100.5018 }; // Example: Bangkok, Thailand
|
||||
// Export the type so the form can use it
|
||||
export { type ShapeData };
|
||||
|
||||
// Define props for the component
|
||||
interface GoogleMapWithDrawingProps {
|
||||
onAreaSelected: (data: { lat: number; lng: number }[]) => void;
|
||||
onShapeDrawn: (data: ShapeData) => void; // Callback prop
|
||||
// Add any other props you might need, e.g., initialCenter, initialZoom
|
||||
initialCenter?: { lat: number; lng: number };
|
||||
initialZoom?: number;
|
||||
}
|
||||
|
||||
// Rename DrawingExample to GoogleMapWithDrawing and accept props
|
||||
const GoogleMapWithDrawing = ({
|
||||
onAreaSelected,
|
||||
onShapeDrawn, // Destructure the callback prop
|
||||
initialCenter = { lat: 13.7563, lng: 100.5018 }, // Default center
|
||||
initialZoom = 10, // Default zoom
|
||||
}: GoogleMapWithDrawingProps) => {
|
||||
const [map, setMap] = useState<google.maps.Map | null>(null);
|
||||
|
||||
const onDrawingComplete = useCallback(
|
||||
(overlay: google.maps.drawing.OverlayCompleteEvent) => {
|
||||
const shape = overlay.overlay;
|
||||
|
||||
// check the shape of the drawing and extract lat/lng values
|
||||
if (shape instanceof google.maps.Polygon) {
|
||||
const path = shape.getPath();
|
||||
const coordinates = path.getArray().map((latLng) => ({
|
||||
lat: latLng.lat(),
|
||||
lng: latLng.lng(),
|
||||
}));
|
||||
console.log("Polygon coordinates:", coordinates);
|
||||
onAreaSelected(coordinates);
|
||||
} else if (shape instanceof google.maps.Rectangle) {
|
||||
const bounds = shape.getBounds();
|
||||
if (bounds) {
|
||||
const northEast = bounds.getNorthEast();
|
||||
const southWest = bounds.getSouthWest();
|
||||
const coordinates = [
|
||||
{ lat: northEast.lat(), lng: northEast.lng() },
|
||||
{ lat: southWest.lat(), lng: southWest.lng() },
|
||||
];
|
||||
console.log("Rectangle coordinates:", coordinates);
|
||||
onAreaSelected(coordinates);
|
||||
}
|
||||
} else if (shape instanceof google.maps.Circle) {
|
||||
const center = shape.getCenter();
|
||||
const radius = shape.getRadius();
|
||||
if (center) {
|
||||
const coordinates = [
|
||||
{
|
||||
lat: center.lat(),
|
||||
lng: center.lng(),
|
||||
radius: radius, // circle's radius in meters
|
||||
},
|
||||
];
|
||||
console.log("Circle center:", coordinates);
|
||||
onAreaSelected(coordinates);
|
||||
}
|
||||
} else if (shape instanceof google.maps.Polyline) {
|
||||
const path = shape.getPath();
|
||||
const coordinates = path.getArray().map((latLng) => ({
|
||||
lat: latLng.lat(),
|
||||
lng: latLng.lng(),
|
||||
}));
|
||||
console.log("Polyline coordinates:", coordinates);
|
||||
onAreaSelected(coordinates);
|
||||
} else {
|
||||
console.log("Unknown shape detected:", shape);
|
||||
}
|
||||
},
|
||||
[onAreaSelected]
|
||||
);
|
||||
// Pass the onShapeDrawn callback directly to the hook
|
||||
const drawingManager = useDrawingManager(onShapeDrawn);
|
||||
|
||||
return (
|
||||
<LoadScript
|
||||
googleMapsApiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!}
|
||||
libraries={["drawing"]}
|
||||
>
|
||||
<GoogleMap
|
||||
mapContainerStyle={containerStyle}
|
||||
center={center}
|
||||
zoom={10}
|
||||
onLoad={(map) => setMap(map)}
|
||||
>
|
||||
{map && (
|
||||
<DrawingManager
|
||||
onOverlayComplete={onDrawingComplete}
|
||||
options={{
|
||||
drawingControl: true,
|
||||
drawingControlOptions: {
|
||||
position: google.maps.ControlPosition.TOP_CENTER,
|
||||
drawingModes: [
|
||||
google.maps.drawing.OverlayType.POLYGON,
|
||||
google.maps.drawing.OverlayType.RECTANGLE,
|
||||
google.maps.drawing.OverlayType.CIRCLE,
|
||||
google.maps.drawing.OverlayType.POLYLINE,
|
||||
],
|
||||
},
|
||||
polygonOptions: {
|
||||
fillColor: "#FF0000",
|
||||
fillOpacity: 0.5,
|
||||
strokeWeight: 2,
|
||||
},
|
||||
rectangleOptions: {
|
||||
fillColor: "#00FF00",
|
||||
fillOpacity: 0.5,
|
||||
strokeWeight: 2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GoogleMap>
|
||||
</LoadScript>
|
||||
<>
|
||||
{/* Use props for map defaults */}
|
||||
<Map
|
||||
defaultZoom={initialZoom}
|
||||
defaultCenter={initialCenter}
|
||||
gestureHandling={"greedy"}
|
||||
disableDefaultUI={true}
|
||||
mapId={"YOUR_MAP_ID"} // Recommended: Add a Map ID
|
||||
/>
|
||||
|
||||
{/* Render controls only if drawingManager is available */}
|
||||
{drawingManager && (
|
||||
<MapControl position={ControlPosition.TOP_LEFT}>
|
||||
{/* Pass drawingManager to UndoRedoControl */}
|
||||
<UndoRedoControl drawingManager={drawingManager} />
|
||||
</MapControl>
|
||||
)}
|
||||
{/* The drawing controls (marker, polygon etc.) are added by useDrawingManager */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
45
frontend/components/map-component/undo-redo-control.tsx
Normal file
45
frontend/components/map-component/undo-redo-control.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { useReducer, useRef } from "react";
|
||||
import { useMap } from "@vis.gl/react-google-maps";
|
||||
|
||||
import reducer, { useDrawingManagerEvents, useOverlaySnapshots } from "@/components/map-component/undo-redo";
|
||||
|
||||
import { DrawingActionKind } from "@/types";
|
||||
|
||||
interface Props {
|
||||
drawingManager: google.maps.drawing.DrawingManager | null;
|
||||
}
|
||||
|
||||
export const UndoRedoControl = ({ drawingManager }: Props) => {
|
||||
const map = useMap();
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
past: [],
|
||||
now: [],
|
||||
future: [],
|
||||
});
|
||||
|
||||
// We need this ref to prevent infinite loops in certain cases.
|
||||
// For example when the radius of circle is set via code (and not by user interaction)
|
||||
// the radius_changed event gets triggered again. This would cause an infinite loop.
|
||||
// This solution can be improved by comparing old vs. new values. For now we turn
|
||||
// off the "updating" when snapshot changes are applied back to the overlays.
|
||||
const overlaysShouldUpdateRef = useRef<boolean>(false);
|
||||
|
||||
useDrawingManagerEvents(drawingManager, overlaysShouldUpdateRef, dispatch);
|
||||
useOverlaySnapshots(map, state, overlaysShouldUpdateRef);
|
||||
|
||||
return (
|
||||
<div className="drawing-history">
|
||||
<button onClick={() => dispatch({ type: DrawingActionKind.UNDO })} disabled={!state.past.length}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path d="M280-200v-80h284q63 0 109.5-40T720-420q0-60-46.5-100T564-560H312l104 104-56 56-200-200 200-200 56 56-104 104h252q97 0 166.5 63T800-420q0 94-69.5 157T564-200H280Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => dispatch({ type: DrawingActionKind.REDO })} disabled={!state.future.length}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path d="M396-200q-97 0-166.5-63T160-420q0-94 69.5-157T396-640h252L544-744l56-56 200 200-200 200-56-56 104-104H396q-63 0-109.5 40T240-420q0 60 46.5 100T396-280h284v80H396Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
217
frontend/components/map-component/undo-redo.ts
Normal file
217
frontend/components/map-component/undo-redo.ts
Normal file
@ -0,0 +1,217 @@
|
||||
import { Dispatch, MutableRefObject, useEffect } from "react";
|
||||
|
||||
import {
|
||||
Action,
|
||||
DrawResult,
|
||||
DrawingActionKind,
|
||||
Overlay,
|
||||
Snapshot,
|
||||
State,
|
||||
isCircle,
|
||||
isMarker,
|
||||
isPolygon,
|
||||
isPolyline,
|
||||
isRectangle,
|
||||
} from "@/types";
|
||||
|
||||
export default function reducer(state: State, action: Action) {
|
||||
switch (action.type) {
|
||||
// This action is called whenever anything changes on any overlay.
|
||||
// We then take a snapshot of the relevant values of each overlay and
|
||||
// save them as the new "now". The old "now" is added to the "past" stack
|
||||
case DrawingActionKind.UPDATE_OVERLAYS: {
|
||||
const overlays = state.now.map((overlay: Overlay) => {
|
||||
const snapshot: Snapshot = {};
|
||||
const { geometry } = overlay;
|
||||
|
||||
if (isCircle(geometry)) {
|
||||
snapshot.center = geometry.getCenter()?.toJSON();
|
||||
snapshot.radius = geometry.getRadius();
|
||||
} else if (isMarker(geometry)) {
|
||||
snapshot.position = geometry.getPosition()?.toJSON();
|
||||
} else if (isPolygon(geometry) || isPolyline(geometry)) {
|
||||
snapshot.path = geometry.getPath()?.getArray();
|
||||
} else if (isRectangle(geometry)) {
|
||||
snapshot.bounds = geometry.getBounds()?.toJSON();
|
||||
}
|
||||
|
||||
return {
|
||||
...overlay,
|
||||
snapshot,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
now: [...overlays],
|
||||
past: [...state.past, state.now],
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
// This action is called when a new overlay is added to the map.
|
||||
// We then take a snapshot of the relevant values of the new overlay and
|
||||
// add it to the "now" state. The old "now" is added to the "past" stack
|
||||
case DrawingActionKind.SET_OVERLAY: {
|
||||
const { overlay } = action.payload;
|
||||
|
||||
const snapshot: Snapshot = {};
|
||||
|
||||
if (isCircle(overlay)) {
|
||||
snapshot.center = overlay.getCenter()?.toJSON();
|
||||
snapshot.radius = overlay.getRadius();
|
||||
} else if (isMarker(overlay)) {
|
||||
snapshot.position = overlay.getPosition()?.toJSON();
|
||||
} else if (isPolygon(overlay) || isPolyline(overlay)) {
|
||||
snapshot.path = overlay.getPath()?.getArray();
|
||||
} else if (isRectangle(overlay)) {
|
||||
snapshot.bounds = overlay.getBounds()?.toJSON();
|
||||
}
|
||||
|
||||
return {
|
||||
past: [...state.past, state.now],
|
||||
now: [
|
||||
...state.now,
|
||||
{
|
||||
type: action.payload.type,
|
||||
geometry: action.payload.overlay,
|
||||
snapshot,
|
||||
},
|
||||
],
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
// This action is called when the undo button is clicked.
|
||||
// Get the top item from the "past" stack and set it as the new "now".
|
||||
// Add the old "now" to the "future" stack to enable redo functionality
|
||||
case DrawingActionKind.UNDO: {
|
||||
const last = state.past.slice(-1)[0];
|
||||
|
||||
if (!last) return state;
|
||||
|
||||
return {
|
||||
past: [...state.past].slice(0, -1),
|
||||
now: last,
|
||||
future: state.now ? [...state.future, state.now] : state.future,
|
||||
};
|
||||
}
|
||||
|
||||
// This action is called when the redo button is clicked.
|
||||
// Get the top item from the "future" stack and set it as the new "now".
|
||||
// Add the old "now" to the "past" stack to enable undo functionality
|
||||
case DrawingActionKind.REDO: {
|
||||
const next = state.future.slice(-1)[0];
|
||||
|
||||
if (!next) return state;
|
||||
|
||||
return {
|
||||
past: state.now ? [...state.past, state.now] : state.past,
|
||||
now: next,
|
||||
future: [...state.future].slice(0, -1),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drawing manager events
|
||||
export function useDrawingManagerEvents(
|
||||
drawingManager: google.maps.drawing.DrawingManager | null,
|
||||
overlaysShouldUpdateRef: MutableRefObject<boolean>,
|
||||
dispatch: Dispatch<Action>
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!drawingManager) return;
|
||||
|
||||
const eventListeners: Array<google.maps.MapsEventListener> = [];
|
||||
|
||||
const addUpdateListener = (eventName: string, drawResult: DrawResult) => {
|
||||
const updateListener = google.maps.event.addListener(drawResult.overlay, eventName, () => {
|
||||
if (eventName === "dragstart") {
|
||||
overlaysShouldUpdateRef.current = false;
|
||||
}
|
||||
|
||||
if (eventName === "dragend") {
|
||||
overlaysShouldUpdateRef.current = true;
|
||||
}
|
||||
|
||||
if (overlaysShouldUpdateRef.current) {
|
||||
dispatch({ type: DrawingActionKind.UPDATE_OVERLAYS });
|
||||
}
|
||||
});
|
||||
|
||||
eventListeners.push(updateListener);
|
||||
};
|
||||
|
||||
const overlayCompleteListener = google.maps.event.addListener(
|
||||
drawingManager,
|
||||
"overlaycomplete",
|
||||
(drawResult: DrawResult) => {
|
||||
switch (drawResult.type) {
|
||||
case google.maps.drawing.OverlayType.CIRCLE:
|
||||
["center_changed", "radius_changed"].forEach((eventName) => addUpdateListener(eventName, drawResult));
|
||||
break;
|
||||
|
||||
case google.maps.drawing.OverlayType.MARKER:
|
||||
["dragend"].forEach((eventName) => addUpdateListener(eventName, drawResult));
|
||||
|
||||
break;
|
||||
|
||||
case google.maps.drawing.OverlayType.POLYGON:
|
||||
case google.maps.drawing.OverlayType.POLYLINE:
|
||||
["mouseup"].forEach((eventName) => addUpdateListener(eventName, drawResult));
|
||||
|
||||
case google.maps.drawing.OverlayType.RECTANGLE:
|
||||
["bounds_changed", "dragstart", "dragend"].forEach((eventName) => addUpdateListener(eventName, drawResult));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
dispatch({ type: DrawingActionKind.SET_OVERLAY, payload: drawResult });
|
||||
}
|
||||
);
|
||||
|
||||
eventListeners.push(overlayCompleteListener);
|
||||
|
||||
return () => {
|
||||
eventListeners.forEach((listener) => google.maps.event.removeListener(listener));
|
||||
};
|
||||
}, [dispatch, drawingManager, overlaysShouldUpdateRef]);
|
||||
}
|
||||
|
||||
// Update overlays with the current "snapshot" when the "now" state changes
|
||||
export function useOverlaySnapshots(
|
||||
map: google.maps.Map | null,
|
||||
state: State,
|
||||
overlaysShouldUpdateRef: MutableRefObject<boolean>
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!map || !state.now) return;
|
||||
|
||||
for (const overlay of state.now) {
|
||||
overlaysShouldUpdateRef.current = false;
|
||||
|
||||
overlay.geometry.setMap(map);
|
||||
|
||||
const { radius, center, position, path, bounds } = overlay.snapshot;
|
||||
|
||||
if (isCircle(overlay.geometry)) {
|
||||
overlay.geometry.setRadius(radius ?? 0);
|
||||
overlay.geometry.setCenter(center ?? null);
|
||||
} else if (isMarker(overlay.geometry)) {
|
||||
overlay.geometry.setPosition(position);
|
||||
} else if (isPolygon(overlay.geometry) || isPolyline(overlay.geometry)) {
|
||||
overlay.geometry.setPath(path ?? []);
|
||||
} else if (isRectangle(overlay.geometry)) {
|
||||
overlay.geometry.setBounds(bounds ?? null);
|
||||
}
|
||||
|
||||
overlaysShouldUpdateRef.current = true;
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const overlay of state.now) {
|
||||
overlay.geometry.setMap(null);
|
||||
}
|
||||
};
|
||||
}, [map, overlaysShouldUpdateRef, state.now]);
|
||||
}
|
||||
139
frontend/components/map-component/use-drawing-manager.tsx
Normal file
139
frontend/components/map-component/use-drawing-manager.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
// use-drawing-manager.tsx
|
||||
import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Define types for the data we'll pass back
|
||||
type MarkerData = { type: "marker"; position: { lat: number; lng: number } };
|
||||
type PolygonData = { type: "polygon"; path: { lat: number; lng: number }[] };
|
||||
type PolylineData = { type: "polyline"; path: { lat: number; lng: number }[] };
|
||||
// Add other types (Rectangle, Circle) if you enable them
|
||||
// type RectangleData = { type: 'rectangle'; bounds: { north: number; east: number; south: number; west: number } };
|
||||
// type CircleData = { type: 'circle'; center: { lat: number; lng: number }; radius: number };
|
||||
|
||||
export type ShapeData = MarkerData | PolygonData | PolylineData; // | RectangleData | CircleData;
|
||||
|
||||
// Add the callback function type to the hook's arguments
|
||||
export function useDrawingManager(
|
||||
onOverlayComplete?: (data: ShapeData) => void,
|
||||
initialValue: google.maps.drawing.DrawingManager | null = null
|
||||
) {
|
||||
const map = useMap();
|
||||
const drawing = useMapsLibrary("drawing");
|
||||
|
||||
const [drawingManager, setDrawingManager] = useState<google.maps.drawing.DrawingManager | null>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !drawing) return;
|
||||
|
||||
const newDrawingManager = new drawing.DrawingManager({
|
||||
map,
|
||||
// drawingMode: google.maps.drawing.OverlayType.MARKER, // You might want to set initial mode to null or let user choose
|
||||
drawingMode: null, // Start without an active drawing mode
|
||||
drawingControl: true,
|
||||
drawingControlOptions: {
|
||||
position: google.maps.ControlPosition.TOP_CENTER,
|
||||
drawingModes: [
|
||||
google.maps.drawing.OverlayType.MARKER,
|
||||
// google.maps.drawing.OverlayType.CIRCLE,
|
||||
google.maps.drawing.OverlayType.POLYGON,
|
||||
google.maps.drawing.OverlayType.POLYLINE,
|
||||
// google.maps.drawing.OverlayType.RECTANGLE,
|
||||
],
|
||||
},
|
||||
markerOptions: {
|
||||
draggable: true,
|
||||
},
|
||||
// circleOptions: { // Uncomment if using circles
|
||||
// editable: false,
|
||||
// },
|
||||
polygonOptions: {
|
||||
editable: true,
|
||||
draggable: true,
|
||||
},
|
||||
// rectangleOptions: { // Uncomment if using rectangles
|
||||
// editable: true,
|
||||
// draggable: true,
|
||||
// },
|
||||
polylineOptions: {
|
||||
editable: true,
|
||||
draggable: true,
|
||||
},
|
||||
});
|
||||
|
||||
setDrawingManager(newDrawingManager);
|
||||
|
||||
// --- Add Event Listener ---
|
||||
const overlayCompleteListener = google.maps.event.addListener(
|
||||
newDrawingManager,
|
||||
"overlaycomplete",
|
||||
(event: google.maps.drawing.OverlayCompleteEvent) => {
|
||||
let data: ShapeData | null = null;
|
||||
const overlay = event.overlay;
|
||||
|
||||
// Extract coordinates based on type
|
||||
switch (event.type) {
|
||||
case google.maps.drawing.OverlayType.MARKER:
|
||||
const marker = overlay as google.maps.Marker;
|
||||
const position = marker.getPosition();
|
||||
if (position) {
|
||||
data = {
|
||||
type: "marker",
|
||||
position: { lat: position.lat(), lng: position.lng() },
|
||||
};
|
||||
}
|
||||
// Optional: remove the drawn marker immediately if you only want the data
|
||||
// marker.setMap(null);
|
||||
break;
|
||||
|
||||
case google.maps.drawing.OverlayType.POLYGON:
|
||||
const polygon = overlay as google.maps.Polygon;
|
||||
const path = polygon.getPath().getArray();
|
||||
data = {
|
||||
type: "polygon",
|
||||
path: path.map((latLng) => ({ lat: latLng.lat(), lng: latLng.lng() })),
|
||||
};
|
||||
// Optional: remove the drawn polygon
|
||||
// polygon.setMap(null);
|
||||
break;
|
||||
|
||||
case google.maps.drawing.OverlayType.POLYLINE:
|
||||
const polyline = overlay as google.maps.Polyline;
|
||||
const linePath = polyline.getPath().getArray();
|
||||
data = {
|
||||
type: "polyline",
|
||||
path: linePath.map((latLng) => ({ lat: latLng.lat(), lng: latLng.lng() })),
|
||||
};
|
||||
// Optional: remove the drawn polyline
|
||||
// polyline.setMap(null);
|
||||
break;
|
||||
|
||||
// Add cases for RECTANGLE and CIRCLE if you enable them
|
||||
|
||||
default:
|
||||
console.warn("Unhandled overlay type:", event.type);
|
||||
break;
|
||||
}
|
||||
|
||||
// Call the callback function if provided and data was extracted
|
||||
if (data && onOverlayComplete) {
|
||||
onOverlayComplete(data);
|
||||
}
|
||||
|
||||
// Optional: Set drawing mode back to null after completion
|
||||
// newDrawingManager.setDrawingMode(null);
|
||||
}
|
||||
);
|
||||
// --- End Event Listener ---
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// Remove the event listener
|
||||
google.maps.event.removeListener(overlayCompleteListener);
|
||||
// Remove the drawing manager from the map
|
||||
newDrawingManager.setMap(null);
|
||||
};
|
||||
// Add onOverlayComplete to dependency array to ensure the latest callback is used
|
||||
}, [map, drawing, onOverlayComplete]);
|
||||
|
||||
return drawingManager;
|
||||
}
|
||||
@ -107,10 +107,11 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
||||
async function getUser() {
|
||||
try {
|
||||
const data = await fetchUserMe();
|
||||
console.log(data);
|
||||
setUser({
|
||||
name: data.user.UUID,
|
||||
email: data.user.Email,
|
||||
avatar: data.user.Avatar || "/avatars/avatar.webp",
|
||||
name: data.user.uuid,
|
||||
email: data.user.email,
|
||||
avatar: data.user.avatar || "/avatars/avatar.webp",
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
|
||||
31
frontend/components/ui/sonner.tsx
Normal file
31
frontend/components/ui/sonner.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
6881
frontend/package-lock.json
generated
Normal file
6881
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -32,6 +32,7 @@
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@vis.gl/react-google-maps": "^1.5.2",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -41,13 +42,14 @@
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "15.1.0",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-themes": "^0.4.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"recharts": "^2.15.1",
|
||||
"sonner": "^2.0.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.2"
|
||||
|
||||
@ -74,6 +74,9 @@ dependencies:
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.2
|
||||
version: 8.21.2(react-dom@19.0.0)(react@19.0.0)
|
||||
'@vis.gl/react-google-maps':
|
||||
specifier: ^1.5.2
|
||||
version: 1.5.2(react-dom@19.0.0)(react@19.0.0)
|
||||
axios:
|
||||
specifier: ^1.7.9
|
||||
version: 1.8.3
|
||||
@ -102,7 +105,7 @@ dependencies:
|
||||
specifier: ^4.24.11
|
||||
version: 4.24.11(next@15.1.0)(react-dom@19.0.0)(react@19.0.0)
|
||||
next-themes:
|
||||
specifier: ^0.4.4
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.0.0)(react@19.0.0)
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
@ -122,6 +125,9 @@ dependencies:
|
||||
recharts:
|
||||
specifier: ^2.15.1
|
||||
version: 2.15.1(react-dom@19.0.0)(react@19.0.0)
|
||||
sonner:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(react-dom@19.0.0)(react@19.0.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.2
|
||||
@ -1815,6 +1821,18 @@ packages:
|
||||
eslint-visitor-keys: 4.2.0
|
||||
dev: true
|
||||
|
||||
/@vis.gl/react-google-maps@1.5.2(react-dom@19.0.0)(react@19.0.0):
|
||||
resolution: {integrity: sha512-0Ypmde7M73GgV4TgcaUTNKXsbcXWToPVuawMNrVg7htXmhpEfLARHwhtmP6N1da3od195ZKC8ShXzC6Vm+zYHQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0 || ^19.0 || ^19.0.0-rc'
|
||||
react-dom: '>=16.8.0 || ^19.0 || ^19.0.0-rc'
|
||||
dependencies:
|
||||
'@types/google.maps': 3.58.1
|
||||
fast-deep-equal: 3.1.3
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
dev: false
|
||||
|
||||
/acorn-jsx@5.3.2(acorn@8.14.1):
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@ -4365,6 +4383,16 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/sonner@2.0.1(react-dom@19.0.0)(react@19.0.0):
|
||||
resolution: {integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
dev: false
|
||||
|
||||
/source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
@ -1,80 +1,171 @@
|
||||
export interface Crop {
|
||||
id: string;
|
||||
farmId: string;
|
||||
export interface GeoPosition {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface GeoMarker {
|
||||
type: "marker";
|
||||
position: GeoPosition;
|
||||
}
|
||||
|
||||
export interface GeoPolygon {
|
||||
type: "polygon";
|
||||
path: GeoPosition[];
|
||||
}
|
||||
|
||||
export interface GeoPolyline {
|
||||
type: "polyline";
|
||||
path: GeoPosition[];
|
||||
}
|
||||
|
||||
export type GeoFeatureData = GeoMarker | GeoPolygon | GeoPolyline;
|
||||
|
||||
export interface Plant {
|
||||
uuid: string;
|
||||
name: string;
|
||||
plantedDate: Date;
|
||||
expectedHarvest?: Date;
|
||||
status: string;
|
||||
variety?: string;
|
||||
area?: string;
|
||||
healthScore?: number;
|
||||
progress?: number;
|
||||
averageHeight?: number;
|
||||
daysToEmerge?: number;
|
||||
daysToFlower?: number;
|
||||
daysToMaturity?: number;
|
||||
estimateLossRate?: number;
|
||||
estimateRevenuePerHu?: number;
|
||||
harvestUnitId: number;
|
||||
harvestWindow?: number;
|
||||
isPerennial: boolean;
|
||||
lightProfileId: number;
|
||||
optimalTemp?: number;
|
||||
phValue?: number;
|
||||
plantingDepth?: number;
|
||||
plantingDetail?: string;
|
||||
rowSpacing?: number;
|
||||
soilConditionId: number;
|
||||
waterNeeds?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Cropland {
|
||||
uuid: string;
|
||||
name: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
landSize: number;
|
||||
growthStage: string;
|
||||
plantId: string;
|
||||
farmId: string;
|
||||
geoFeature?: GeoFeatureData | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CropAnalytics {
|
||||
cropId: string;
|
||||
cropName: string;
|
||||
farmId: string;
|
||||
plantName: string;
|
||||
variety?: string;
|
||||
currentStatus: string;
|
||||
growthStage: string;
|
||||
landSize: number;
|
||||
lastUpdated: string;
|
||||
temperature?: number | null;
|
||||
humidity?: number | null;
|
||||
soilMoisture?: number | null;
|
||||
sunlight?: number | null;
|
||||
windSpeed?: number | null;
|
||||
rainfall?: number | null;
|
||||
growthProgress: number;
|
||||
humidity: number;
|
||||
temperature: number;
|
||||
sunlight: number;
|
||||
waterLevel: number;
|
||||
plantHealth: "good" | "warning" | "critical";
|
||||
nextAction: string;
|
||||
nextActionDue: Date;
|
||||
soilMoisture: number;
|
||||
windSpeed: string;
|
||||
rainfall: string;
|
||||
nutrientLevels: {
|
||||
nitrogen: number;
|
||||
phosphorus: number;
|
||||
potassium: number;
|
||||
};
|
||||
plantHealth?: "good" | "warning" | "critical";
|
||||
nextAction?: string | null;
|
||||
nextActionDue?: string | null;
|
||||
nutrientLevels?: {
|
||||
nitrogen: number | null;
|
||||
phosphorus: number | null;
|
||||
potassium: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Farm {
|
||||
id: string;
|
||||
uuid: string;
|
||||
name: string;
|
||||
location: string;
|
||||
type: string;
|
||||
farmType: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
ownerId: string;
|
||||
totalSize: string;
|
||||
createdAt: Date;
|
||||
area?: string;
|
||||
crops: number;
|
||||
weather?: {
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
rainfall: string;
|
||||
sunlight: number;
|
||||
};
|
||||
updatedAt: Date;
|
||||
crops: Cropland[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
ID: number;
|
||||
UUID: string;
|
||||
Username: string;
|
||||
Password: string;
|
||||
Email: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
Avatar: string;
|
||||
IsActive: boolean;
|
||||
id: number;
|
||||
uuid: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
avatar?: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type InventoryItem = {
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
categoryId: number;
|
||||
category: { id: number; name: string };
|
||||
quantity: number;
|
||||
unitId: number;
|
||||
unit: { id: number; name: string };
|
||||
dateAdded: string;
|
||||
statusId: number;
|
||||
status: { id: number; name: string };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InventoryStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface InventoryCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface HarvestUnit {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type InventoryItemCategory = {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
lastUpdated: string;
|
||||
status: string;
|
||||
};
|
||||
export type InventoryItemStatus = {
|
||||
export type HarvestUnits = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CreateInventoryItemInput = Omit<InventoryItem, "id" | "lastUpdated" | "status">;
|
||||
export type CreateInventoryItemInput = {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
quantity: number;
|
||||
unitId: number;
|
||||
dateAdded: string;
|
||||
statusId: number;
|
||||
};
|
||||
|
||||
// export type UpdateInventoryItemInput = CreateInventoryItemInput & {};
|
||||
export type EditInventoryItemInput = CreateInventoryItemInput;
|
||||
|
||||
export type UpdateInventoryItemInput = Partial<CreateInventoryItemInput> & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface Blog {
|
||||
id: number;
|
||||
@ -100,3 +191,83 @@ export interface Blog {
|
||||
featured?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type OverlayGeometry =
|
||||
| google.maps.Marker
|
||||
| google.maps.Polygon
|
||||
| google.maps.Polyline
|
||||
| google.maps.Rectangle
|
||||
| google.maps.Circle;
|
||||
|
||||
export interface DrawResult {
|
||||
type: google.maps.drawing.OverlayType;
|
||||
overlay: OverlayGeometry;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
radius?: number;
|
||||
center?: google.maps.LatLngLiteral;
|
||||
position?: google.maps.LatLngLiteral;
|
||||
path?: Array<google.maps.LatLng>;
|
||||
bounds?: google.maps.LatLngBoundsLiteral;
|
||||
}
|
||||
|
||||
export interface Overlay {
|
||||
type: google.maps.drawing.OverlayType;
|
||||
geometry: OverlayGeometry;
|
||||
snapshot: Snapshot;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
past: Array<Array<Overlay>>;
|
||||
now: Array<Overlay>;
|
||||
future: Array<Array<Overlay>>;
|
||||
}
|
||||
|
||||
export enum DrawingActionKind {
|
||||
SET_OVERLAY = "SET_OVERLAY",
|
||||
UPDATE_OVERLAYS = "UPDATE_OVERLAYS",
|
||||
UNDO = "UNDO",
|
||||
REDO = "REDO",
|
||||
}
|
||||
|
||||
export interface ActionWithTypeOnly {
|
||||
type: Exclude<DrawingActionKind, DrawingActionKind.SET_OVERLAY>;
|
||||
}
|
||||
|
||||
export interface SetOverlayAction {
|
||||
type: DrawingActionKind.SET_OVERLAY;
|
||||
payload: DrawResult;
|
||||
}
|
||||
|
||||
export type Action = ActionWithTypeOnly | SetOverlayAction;
|
||||
|
||||
export function isCircle(
|
||||
overlay: OverlayGeometry
|
||||
): overlay is google.maps.Circle {
|
||||
return (overlay as google.maps.Circle).getCenter !== undefined;
|
||||
}
|
||||
|
||||
export function isMarker(
|
||||
overlay: OverlayGeometry
|
||||
): overlay is google.maps.Marker {
|
||||
return (overlay as google.maps.Marker).getPosition !== undefined;
|
||||
}
|
||||
|
||||
export function isPolygon(
|
||||
overlay: OverlayGeometry
|
||||
): overlay is google.maps.Polygon {
|
||||
return (overlay as google.maps.Polygon).getPath !== undefined;
|
||||
}
|
||||
|
||||
export function isPolyline(
|
||||
overlay: OverlayGeometry
|
||||
): overlay is google.maps.Polyline {
|
||||
return (overlay as google.maps.Polyline).getPath !== undefined;
|
||||
}
|
||||
|
||||
export function isRectangle(
|
||||
overlay: OverlayGeometry
|
||||
): overlay is google.maps.Rectangle {
|
||||
return (overlay as google.maps.Rectangle).getBounds !== undefined;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user