mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-18 13:34:08 +01:00
Merge branch 'main' into feature-inventory
This commit is contained in:
commit
65cae90549
@ -9,6 +9,7 @@ require (
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||
github.com/gofrs/uuid v4.4.0+incompatible
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/generative-ai-go v0.19.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
@ -17,12 +18,28 @@ require (
|
||||
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
|
||||
golang.org/x/crypto v0.36.0
|
||||
google.golang.org/api v0.186.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.115.0 // indirect
|
||||
cloud.google.com/go/ai v0.8.0 // indirect
|
||||
cloud.google.com/go/auth v0.6.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.5.7 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@ -41,11 +58,24 @@ require (
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
|
||||
go.opentelemetry.io/otel v1.26.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.26.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.26.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect
|
||||
google.golang.org/grpc v1.64.1 // indirect
|
||||
google.golang.org/protobuf v1.35.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
162
backend/go.sum
162
backend/go.sum
@ -1,5 +1,22 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
|
||||
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
|
||||
cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
|
||||
cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
|
||||
cloud.google.com/go/auth v0.6.0 h1:5x+d6b5zdezZ7gmLWD1m/xNjnaQ2YDhmIz/HH3doy1g=
|
||||
cloud.google.com/go/auth v0.6.0/go.mod h1:b4acV+jLQDyjwm4OXHYjNvRi4jvGBzHWJRtJcy+2P4g=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
|
||||
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/danielgtaylor/huma/v2 v2.28.0 h1:W+hIT52MigO73edJNJWXU896uC99xSBWpKoE2PRyybM=
|
||||
github.com/danielgtaylor/huma/v2 v2.28.0/go.mod h1:67KO0zmYEkR+LVUs8uqrcvf44G1wXiMIu94LV/cH2Ek=
|
||||
@ -9,6 +26,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
@ -17,16 +40,52 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
|
||||
github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
|
||||
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
@ -67,6 +126,7 @@ 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
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=
|
||||
@ -94,27 +154,107 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
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.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
|
||||
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
|
||||
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
|
||||
go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30=
|
||||
go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4=
|
||||
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
|
||||
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
|
||||
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=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
|
||||
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.186.0 h1:n2OPp+PPXX0Axh4GuSsL5QL8xQCTb2oDwyzPnQvqUug=
|
||||
google.golang.org/api v0.186.0/go.mod h1:hvRbBmgoje49RV3xqVXrmP6w93n6ehGgIVPYrGtBFFc=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 h1:MuYw1wJzT+ZkybKfaOXKp5hJiZDn2iHaXRw0mRYdHSc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4/go.mod h1:px9SlOOZBg1wM1zdnr8jEL4CNGUBZ+ZKYtNPApNQc4c=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
||||
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@ -124,6 +264,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
|
||||
142
backend/internal/api/chat.go
Normal file
142
backend/internal/api/chat.go
Normal file
@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/forfarm/backend/internal/services"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/generative-ai-go/genai"
|
||||
)
|
||||
|
||||
func (a *api) registerChatRoutes(_ chi.Router, apiInstance huma.API) {
|
||||
tags := []string{"chat"}
|
||||
|
||||
huma.Register(apiInstance, huma.Operation{
|
||||
OperationID: "chatWithAssistantContextual",
|
||||
Method: http.MethodPost,
|
||||
Path: "/chat/specific",
|
||||
Tags: tags,
|
||||
Summary: "Send a message to the assistant with farm/crop context",
|
||||
Description: "Allows users to interact with the AI chatbot, providing farm/crop context.",
|
||||
}, a.chatHandler)
|
||||
|
||||
huma.Register(apiInstance, huma.Operation{
|
||||
OperationID: "chatWithAssistantGeneral",
|
||||
Method: http.MethodPost,
|
||||
Path: "/chat",
|
||||
Tags: tags,
|
||||
Summary: "Send a message to the general farming assistant",
|
||||
Description: "Allows users to interact with the AI chatbot without specific farm/crop context.",
|
||||
}, a.generalChatHandler)
|
||||
}
|
||||
|
||||
type HistoryItem struct {
|
||||
Role string `json:"role" example:"user" enum:"user,model" doc:"Role of the sender (user or model)"`
|
||||
Text string `json:"text" doc:"The content of the message"`
|
||||
}
|
||||
|
||||
type ChatInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
Body struct {
|
||||
Message string `json:"message" required:"true" doc:"The user's message to the assistant"`
|
||||
FarmID string `json:"farmId,omitempty" doc:"Optional UUID of the farm context"`
|
||||
CropID string `json:"cropId,omitempty" doc:"Optional UUID of the crop context"`
|
||||
History []HistoryItem `json:"history,omitempty" doc:"Previous turns in the conversation"`
|
||||
}
|
||||
}
|
||||
|
||||
type ChatOutput struct {
|
||||
Body struct {
|
||||
Response string `json:"response" doc:"The assistant's response message"`
|
||||
}
|
||||
}
|
||||
|
||||
type GeneralChatInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
Body struct {
|
||||
Message string `json:"message" required:"true" doc:"The user's message to the assistant"`
|
||||
History []HistoryItem `json:"history,omitempty" doc:"Previous turns in the conversation"`
|
||||
}
|
||||
}
|
||||
|
||||
type GeneralChatOutput = ChatOutput
|
||||
|
||||
// convertInputHistory converts the API HistoryItem slice to genai.Content slice.
|
||||
func convertInputHistory(apiHistory []HistoryItem) []*genai.Content {
|
||||
if apiHistory == nil {
|
||||
return nil
|
||||
}
|
||||
genaiHistory := make([]*genai.Content, 0, len(apiHistory))
|
||||
for _, item := range apiHistory {
|
||||
if (item.Role == "user" || item.Role == "model") && item.Text != "" {
|
||||
content := &genai.Content{
|
||||
Role: item.Role,
|
||||
Parts: []genai.Part{genai.Text(item.Text)},
|
||||
}
|
||||
genaiHistory = append(genaiHistory, content)
|
||||
}
|
||||
}
|
||||
return genaiHistory
|
||||
}
|
||||
|
||||
func (a *api) chatHandler(ctx context.Context, input *ChatInput) (*ChatOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
if a.chatService == nil {
|
||||
a.logger.Error("Chat service is not initialized")
|
||||
return nil, huma.Error500InternalServerError("Chat service is not available")
|
||||
}
|
||||
|
||||
genaiHistory := convertInputHistory(input.Body.History)
|
||||
|
||||
serviceInput := services.GenerateResponseInput{
|
||||
UserID: userID,
|
||||
Message: input.Body.Message,
|
||||
FarmID: input.Body.FarmID,
|
||||
CropID: input.Body.CropID,
|
||||
History: genaiHistory,
|
||||
}
|
||||
|
||||
botResponse, err := a.chatService.GenerateResponse(ctx, serviceInput)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to get response from chat service (contextual)", "userId", userID, "farmId", input.Body.FarmID, "cropId", input.Body.CropID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to get response from assistant")
|
||||
}
|
||||
|
||||
resp := &ChatOutput{}
|
||||
resp.Body.Response = botResponse
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *api) generalChatHandler(ctx context.Context, input *GeneralChatInput) (*GeneralChatOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
if a.chatService == nil {
|
||||
a.logger.Error("Chat service is not initialized")
|
||||
return nil, huma.Error500InternalServerError("Chat service is not available")
|
||||
}
|
||||
|
||||
genaiHistory := convertInputHistory(input.Body.History)
|
||||
|
||||
serviceInput := services.GenerateResponseInput{
|
||||
UserID: userID,
|
||||
Message: input.Body.Message,
|
||||
History: genaiHistory,
|
||||
}
|
||||
|
||||
botResponse, err := a.chatService.GenerateResponse(ctx, serviceInput)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to get response from chat service (general)", "userId", userID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to get response from assistant")
|
||||
}
|
||||
|
||||
resp := &GeneralChatOutput{}
|
||||
resp.Body.Response = botResponse
|
||||
return resp, nil
|
||||
}
|
||||
@ -15,10 +15,8 @@ import (
|
||||
|
||||
func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
|
||||
tags := []string{"crop"}
|
||||
|
||||
prefix := "/crop"
|
||||
|
||||
// Register GET /crop
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getAllCroplands",
|
||||
Method: http.MethodGet,
|
||||
@ -26,7 +24,6 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
|
||||
Tags: tags,
|
||||
}, a.getAllCroplandsHandler)
|
||||
|
||||
// Register GET /crop/{uuid}
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getCroplandByID",
|
||||
Method: http.MethodGet,
|
||||
@ -34,7 +31,6 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
|
||||
Tags: tags,
|
||||
}, a.getCroplandByIDHandler)
|
||||
|
||||
// Register GET /crop/farm/{farm_id}
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "getAllCroplandsByFarmID",
|
||||
Method: http.MethodGet,
|
||||
@ -42,15 +38,23 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
|
||||
Tags: tags,
|
||||
}, a.getAllCroplandsByFarmIDHandler)
|
||||
|
||||
// Register POST /crop (Create or Update)
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "createOrUpdateCropland",
|
||||
OperationID: "createCropland",
|
||||
Method: http.MethodPost,
|
||||
Path: prefix,
|
||||
Tags: tags,
|
||||
}, a.createOrUpdateCroplandHandler)
|
||||
}, a.createCroplandHandler)
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "updateCropland",
|
||||
Method: http.MethodPut,
|
||||
Path: prefix + "/{uuid}",
|
||||
Tags: tags,
|
||||
}, a.updateCroplandHandler)
|
||||
}
|
||||
|
||||
// --- Common Output Structs ---
|
||||
|
||||
type GetCroplandsOutput struct {
|
||||
Body struct {
|
||||
Croplands []domain.Cropland `json:"croplands"`
|
||||
@ -63,22 +67,45 @@ type GetCroplandByIDOutput struct {
|
||||
}
|
||||
}
|
||||
|
||||
type CreateOrUpdateCroplandInput struct {
|
||||
// --- Create Structs ---
|
||||
|
||||
type CreateCroplandInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
Body struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Status string `json:"status" required:"true"`
|
||||
Priority int `json:"priority"`
|
||||
LandSize float64 `json:"landSize"`
|
||||
GrowthStage string `json:"growthStage"`
|
||||
PlantID string `json:"plantId"`
|
||||
FarmID string `json:"farmId"`
|
||||
GrowthStage string `json:"growthStage" required:"true"`
|
||||
PlantID string `json:"plantId" required:"true" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"`
|
||||
FarmID string `json:"farmId" required:"true" example:"b2c3d4e5-f6a7-8901-2345-67890abcdef0"`
|
||||
GeoFeature json.RawMessage `json:"geoFeature,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
type CreateOrUpdateCroplandOutput struct {
|
||||
type CreateCroplandOutput struct {
|
||||
Body struct {
|
||||
Cropland domain.Cropland `json:"cropland"`
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update Structs ---
|
||||
|
||||
type UpdateCroplandInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
UUID string `path:"uuid" required:"true" example:"c3d4e5f6-a7b8-9012-3456-7890abcdef01"`
|
||||
Body struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Status string `json:"status" required:"true"`
|
||||
Priority int `json:"priority"`
|
||||
LandSize float64 `json:"landSize"`
|
||||
GrowthStage string `json:"growthStage" required:"true"`
|
||||
PlantID string `json:"plantId" required:"true" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"`
|
||||
GeoFeature json.RawMessage `json:"geoFeature,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateCroplandOutput struct {
|
||||
Body struct {
|
||||
Cropland domain.Cropland `json:"cropland"`
|
||||
}
|
||||
@ -90,8 +117,7 @@ func (a *api) getAllCroplandsHandler(ctx context.Context, input *struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
}) (*GetCroplandsOutput, error) {
|
||||
// Note: This currently fetches ALL croplands. Might need owner filtering later.
|
||||
// For now, ensure authentication happens.
|
||||
_, err := a.getUserIDFromHeader(input.Header) // Verify token
|
||||
_, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
@ -112,7 +138,7 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"`
|
||||
}) (*GetCroplandByIDOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header) // Verify token and get user ID
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
@ -120,15 +146,15 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct {
|
||||
resp := &GetCroplandByIDOutput{}
|
||||
|
||||
if input.UUID == "" {
|
||||
return nil, huma.Error400BadRequest("UUID parameter is required")
|
||||
return nil, huma.Error400BadRequest("UUID path parameter is required")
|
||||
}
|
||||
|
||||
_, err = uuid.FromString(input.UUID)
|
||||
croplandUUID, err := uuid.FromString(input.UUID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("Invalid UUID format")
|
||||
}
|
||||
|
||||
cropland, err := a.cropRepo.GetByID(ctx, input.UUID)
|
||||
cropland, err := a.cropRepo.GetByID(ctx, croplandUUID.String())
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Warn("Cropland not found", "croplandId", input.UUID, "requestingUserId", userID)
|
||||
@ -138,12 +164,10 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct {
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve cropland")
|
||||
}
|
||||
|
||||
// Authorization check: User must own the farm this cropland belongs to
|
||||
farm, err := a.farmRepo.GetByID(ctx, cropland.FarmID) // Fetch the farm
|
||||
farm, err := a.farmRepo.GetByID(ctx, cropland.FarmID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Error("Farm associated with cropland not found", "farmId", cropland.FarmID, "croplandId", input.UUID)
|
||||
// This indicates a data integrity issue if the cropland exists but farm doesn't
|
||||
return nil, huma.Error404NotFound("Associated farm not found for cropland")
|
||||
}
|
||||
a.logger.Error("Failed to fetch farm for cropland authorization", "farmId", cropland.FarmID, "error", err)
|
||||
@ -171,7 +195,7 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct
|
||||
resp := &GetCroplandsOutput{}
|
||||
|
||||
if input.FarmID == "" {
|
||||
return nil, huma.Error400BadRequest("farm_id parameter is required")
|
||||
return nil, huma.Error400BadRequest("farmId path parameter is required")
|
||||
}
|
||||
|
||||
farmUUID, err := uuid.FromString(input.FarmID)
|
||||
@ -179,7 +203,6 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct
|
||||
return nil, huma.Error400BadRequest("Invalid farmId format")
|
||||
}
|
||||
|
||||
// Authorization check: User must own the farm they are requesting crops for
|
||||
farm, err := a.farmRepo.GetByID(ctx, farmUUID.String())
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
@ -208,83 +231,41 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOrUpdateCroplandInput) (*CreateOrUpdateCroplandOutput, error) {
|
||||
func (a *api) createCroplandHandler(ctx context.Context, input *CreateCroplandInput) (*CreateCroplandOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
resp := &CreateOrUpdateCroplandOutput{}
|
||||
resp := &CreateCroplandOutput{}
|
||||
|
||||
// --- Input Validation ---
|
||||
if input.Body.Name == "" {
|
||||
return nil, huma.Error400BadRequest("name is required")
|
||||
}
|
||||
if input.Body.Status == "" {
|
||||
return nil, huma.Error400BadRequest("status is required")
|
||||
}
|
||||
if input.Body.GrowthStage == "" {
|
||||
return nil, huma.Error400BadRequest("growthStage is required")
|
||||
}
|
||||
if input.Body.PlantID == "" {
|
||||
return nil, huma.Error400BadRequest("plantId is required")
|
||||
}
|
||||
if input.Body.FarmID == "" {
|
||||
return nil, huma.Error400BadRequest("farmId is required")
|
||||
}
|
||||
|
||||
// Validate UUID formats
|
||||
if input.Body.UUID != "" {
|
||||
if _, err := uuid.FromString(input.Body.UUID); err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid cropland UUID format")
|
||||
}
|
||||
}
|
||||
if _, err := uuid.FromString(input.Body.PlantID); err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid plantId UUID format")
|
||||
}
|
||||
farmUUID, err := uuid.FromString(input.Body.FarmID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid farm_id UUID format")
|
||||
return nil, huma.Error400BadRequest("invalid farmId UUID format")
|
||||
}
|
||||
|
||||
// Validate JSON format if GeoFeature is provided
|
||||
if input.Body.GeoFeature != nil && !json.Valid(input.Body.GeoFeature) {
|
||||
return nil, huma.Error400BadRequest("invalid JSON format for geoFeature")
|
||||
}
|
||||
|
||||
// --- Authorization Check ---
|
||||
// User must own the farm they are adding/updating a crop for
|
||||
farm, err := a.farmRepo.GetByID(ctx, farmUUID.String())
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Warn("Attempt to create/update crop for non-existent farm", "farmId", input.Body.FarmID, "requestingUserId", userID)
|
||||
a.logger.Warn("Attempt to create crop for non-existent farm", "farmId", input.Body.FarmID, "requestingUserId", userID)
|
||||
return nil, huma.Error404NotFound("Target farm not found")
|
||||
}
|
||||
a.logger.Error("Failed to fetch farm for create/update cropland authorization", "farmId", input.Body.FarmID, "error", err)
|
||||
a.logger.Error("Failed to fetch farm for create cropland authorization", "farmId", input.Body.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to verify ownership")
|
||||
}
|
||||
if farm.OwnerID != userID {
|
||||
a.logger.Warn("Unauthorized attempt to create/update crop on farm", "farmId", input.Body.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to modify crops on this farm")
|
||||
a.logger.Warn("Unauthorized attempt to create crop on farm", "farmId", input.Body.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to add crops to this farm")
|
||||
}
|
||||
|
||||
// If updating, ensure the user also owns the existing cropland (redundant if farm check passes, but good practice)
|
||||
if input.Body.UUID != "" {
|
||||
existingCrop, err := a.cropRepo.GetByID(ctx, input.Body.UUID)
|
||||
if err != nil && !errors.Is(err, domain.ErrNotFound) && !errors.Is(err, sql.ErrNoRows) { // Ignore not found for creation
|
||||
a.logger.Error("Failed to get existing cropland for update authorization check", "croplandId", input.Body.UUID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to verify existing cropland")
|
||||
}
|
||||
// If cropland exists and its FarmID doesn't match the input/authorized FarmID, deny.
|
||||
if err == nil && existingCrop.FarmID != farmUUID.String() {
|
||||
a.logger.Warn("Attempt to update cropland belonging to a different farm", "croplandId", input.Body.UUID, "inputFarmId", input.Body.FarmID, "actualFarmId", existingCrop.FarmID)
|
||||
return nil, huma.Error403Forbidden("Cropland does not belong to the specified farm")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prepare and Save Cropland ---
|
||||
cropland := &domain.Cropland{
|
||||
UUID: input.Body.UUID,
|
||||
Name: input.Body.Name,
|
||||
Status: input.Body.Status,
|
||||
Priority: input.Body.Priority,
|
||||
@ -295,15 +276,84 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr
|
||||
GeoFeature: input.Body.GeoFeature,
|
||||
}
|
||||
|
||||
// Use the repository's CreateOrUpdate which handles assigning UUID if needed
|
||||
err = a.cropRepo.CreateOrUpdate(ctx, cropland)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to save cropland to database", "farm_id", input.Body.FarmID, "plantId", input.Body.PlantID, "error", err)
|
||||
a.logger.Error("Failed to create cropland in database", "farmId", input.Body.FarmID, "plantId", input.Body.PlantID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to save cropland")
|
||||
}
|
||||
|
||||
a.logger.Info("Cropland created/updated successfully", "croplandId", cropland.UUID, "farmId", cropland.FarmID)
|
||||
a.logger.Info("Cropland created successfully", "croplandId", cropland.UUID, "farmId", cropland.FarmID)
|
||||
|
||||
resp.Body.Cropland = *cropland
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *api) updateCroplandHandler(ctx context.Context, input *UpdateCroplandInput) (*UpdateCroplandOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Header)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
resp := &UpdateCroplandOutput{}
|
||||
|
||||
croplandUUID, err := uuid.FromString(input.UUID)
|
||||
if err != nil {
|
||||
return nil, huma.Error400BadRequest("Invalid cropland UUID format in path")
|
||||
}
|
||||
|
||||
if _, err := uuid.FromString(input.Body.PlantID); err != nil {
|
||||
return nil, huma.Error400BadRequest("invalid plantId UUID format in body")
|
||||
}
|
||||
|
||||
if input.Body.GeoFeature != nil && !json.Valid(input.Body.GeoFeature) {
|
||||
return nil, huma.Error400BadRequest("invalid JSON format for geoFeature")
|
||||
}
|
||||
|
||||
existingCrop, err := a.cropRepo.GetByID(ctx, croplandUUID.String())
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Warn("Attempt to update non-existent cropland", "croplandId", input.UUID, "requestingUserId", userID)
|
||||
return nil, huma.Error404NotFound("Cropland not found")
|
||||
}
|
||||
a.logger.Error("Failed to get existing cropland for update", "croplandId", input.UUID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve cropland for update")
|
||||
}
|
||||
|
||||
farm, err := a.farmRepo.GetByID(ctx, existingCrop.FarmID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
|
||||
a.logger.Error("Farm associated with existing cropland not found during update", "farmId", existingCrop.FarmID, "croplandId", input.UUID)
|
||||
return nil, huma.Error500InternalServerError("Associated farm data inconsistent")
|
||||
}
|
||||
a.logger.Error("Failed to fetch farm for update cropland authorization", "farmId", existingCrop.FarmID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to verify ownership for update")
|
||||
}
|
||||
if farm.OwnerID != userID {
|
||||
a.logger.Warn("Unauthorized attempt to update crop on farm", "croplandId", input.UUID, "farmId", existingCrop.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID)
|
||||
return nil, huma.Error403Forbidden("You are not authorized to modify this cropland")
|
||||
}
|
||||
|
||||
updatedCropland := &domain.Cropland{
|
||||
UUID: existingCrop.UUID,
|
||||
FarmID: existingCrop.FarmID,
|
||||
Name: input.Body.Name,
|
||||
Status: input.Body.Status,
|
||||
Priority: input.Body.Priority,
|
||||
LandSize: input.Body.LandSize,
|
||||
GrowthStage: input.Body.GrowthStage,
|
||||
PlantID: input.Body.PlantID,
|
||||
GeoFeature: input.Body.GeoFeature,
|
||||
CreatedAt: existingCrop.CreatedAt,
|
||||
}
|
||||
|
||||
err = a.cropRepo.CreateOrUpdate(ctx, updatedCropland)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to update cropland in database", "croplandId", updatedCropland.UUID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to update cropland")
|
||||
}
|
||||
|
||||
a.logger.Info("Cropland updated successfully", "croplandId", updatedCropland.UUID, "farmId", updatedCropland.FarmID)
|
||||
|
||||
resp.Body.Cropland = *updatedCropland
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@ -11,6 +11,8 @@ import (
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/forfarm/backend/internal/utilities"
|
||||
"github.com/go-chi/chi/v5"
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (a *api) registerUserRoutes(_ chi.Router, api huma.API) {
|
||||
@ -29,13 +31,25 @@ 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"`
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateSelfDataInput struct {
|
||||
Authorization string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
Body struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
type UpdateSelfDataOutput struct {
|
||||
Body struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
}
|
||||
|
||||
func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSelfDataOutput, error) {
|
||||
resp := &getSelfDataOutput{}
|
||||
|
||||
@ -71,3 +85,70 @@ func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSel
|
||||
resp.Body.User = user
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *api) updateSelfData(ctx context.Context, input *UpdateSelfDataInput) (*UpdateSelfDataOutput, error) {
|
||||
userID, err := a.getUserIDFromHeader(input.Authorization)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
|
||||
user, err := a.userRepo.GetByUUID(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, pgx.ErrNoRows) {
|
||||
a.logger.Warn("Attempt to update non-existent user", "user_uuid", userID)
|
||||
return nil, huma.Error404NotFound("User not found")
|
||||
}
|
||||
a.logger.Error("Failed to get user for update", "user_uuid", userID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to retrieve user for update")
|
||||
}
|
||||
|
||||
updated := false
|
||||
if input.Body.Username != nil {
|
||||
trimmedUsername := strings.TrimSpace(*input.Body.Username)
|
||||
if trimmedUsername != user.Username {
|
||||
err := validation.Validate(trimmedUsername,
|
||||
validation.Required.Error("username cannot be empty if provided"),
|
||||
validation.Length(3, 30).Error("username must be between 3 and 30 characters"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, huma.Error422UnprocessableEntity("Invalid username", err)
|
||||
}
|
||||
user.Username = trimmedUsername
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
// Check other field here la
|
||||
|
||||
if !updated {
|
||||
a.logger.Info("No changes detected for user update", "user_uuid", userID)
|
||||
return &UpdateSelfDataOutput{Body: struct {
|
||||
User domain.User `json:"user"`
|
||||
}{User: user}}, nil
|
||||
}
|
||||
|
||||
// Validate the *entire* user object after updates (optional but good practice)
|
||||
// if err := user.Validate(); err != nil {
|
||||
// return nil, huma.Error422UnprocessableEntity("Validation failed after update", err)
|
||||
// }
|
||||
|
||||
// Save updated user
|
||||
err = a.userRepo.CreateOrUpdate(ctx, &user)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to update user in database", "user_uuid", userID, "error", err)
|
||||
return nil, huma.Error500InternalServerError("Failed to save user profile")
|
||||
}
|
||||
|
||||
a.logger.Info("User profile updated successfully", "user_uuid", userID)
|
||||
|
||||
updatedUser, fetchErr := a.userRepo.GetByUUID(ctx, userID)
|
||||
if fetchErr != nil {
|
||||
a.logger.Error("Failed to fetch user data after update", "user_uuid", userID, "error", fetchErr)
|
||||
return &UpdateSelfDataOutput{Body: struct {
|
||||
User domain.User `json:"user"`
|
||||
}{User: user}}, nil
|
||||
}
|
||||
|
||||
return &UpdateSelfDataOutput{Body: struct {
|
||||
User domain.User `json:"user"`
|
||||
}{User: updatedUser}}, nil
|
||||
}
|
||||
|
||||
@ -68,18 +68,21 @@ func APICmd(ctx context.Context) *cobra.Command {
|
||||
}()
|
||||
logger.Info("Farm Analytics Projection started")
|
||||
|
||||
weatherFetcher := api.GetWeatherFetcher() // Get fetcher instance from API setup
|
||||
apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, inventoryRepo, croplandRepo, farmRepo)
|
||||
|
||||
weatherFetcher := apiInstance.GetWeatherFetcher()
|
||||
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
|
||||
weatherUpdater, err := workers.NewWeatherUpdater(farmRepo, weatherFetcher, eventBus, logger, weatherInterval)
|
||||
if err != nil {
|
||||
logger.Error("failed to create WeatherUpdater", "error", err)
|
||||
}
|
||||
weatherUpdater.Start(ctx)
|
||||
logger.Info("Weather Updater worker started", "interval", weatherInterval)
|
||||
|
||||
apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, inventoryRepo, croplandRepo, farmRepo) // Pass new repo
|
||||
|
||||
server := apiInstance.Server(port)
|
||||
|
||||
serverErrChan := make(chan error, 1)
|
||||
@ -87,7 +90,7 @@ func APICmd(ctx context.Context) *cobra.Command {
|
||||
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
|
||||
serverErrChan <- err
|
||||
}
|
||||
close(serverErrChan)
|
||||
}()
|
||||
@ -98,11 +101,10 @@ func APICmd(ctx context.Context) *cobra.Command {
|
||||
case <-ctx.Done():
|
||||
logger.Info("Shutdown signal received, initiating graceful shutdown...")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) // 15-second grace period
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
weatherUpdater.Stop() // Signal and wait
|
||||
|
||||
weatherUpdater.Stop()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("HTTP server graceful shutdown failed", "error", err)
|
||||
} else {
|
||||
|
||||
@ -20,6 +20,7 @@ var (
|
||||
OPENWEATHER_API_KEY string
|
||||
OPENWEATHER_CACHE_TTL string
|
||||
WEATHER_FETCH_INTERVAL string
|
||||
GEMINI_API_KEY string
|
||||
)
|
||||
|
||||
func Load() {
|
||||
@ -36,6 +37,7 @@ func Load() {
|
||||
viper.SetDefault("OPENWEATHER_API_KEY", "openweather_api_key")
|
||||
viper.SetDefault("OPENWEATHER_CACHE_TTL", "15m")
|
||||
viper.SetDefault("WEATHER_FETCH_INTERVAL", "15m")
|
||||
viper.SetDefault("GEMINI_API_KEY", "gemini_api_key")
|
||||
|
||||
viper.SetConfigFile(".env")
|
||||
viper.AddConfigPath("../../.")
|
||||
@ -59,4 +61,5 @@ func Load() {
|
||||
OPENWEATHER_API_KEY = viper.GetString("OPENWEATHER_API_KEY")
|
||||
OPENWEATHER_CACHE_TTL = viper.GetString("OPENWEATHER_CACHE_TTL")
|
||||
WEATHER_FETCH_INTERVAL = viper.GetString("WEATHER_FETCH_INTERVAL")
|
||||
GEMINI_API_KEY = viper.GetString("GEMINI_API_KEY")
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ func (f *Farm) Validate() error {
|
||||
type FarmRepository interface {
|
||||
GetByID(context.Context, string) (*Farm, error)
|
||||
GetByOwnerID(context.Context, string) ([]Farm, error)
|
||||
GetAll(context.Context) ([]Farm, error)
|
||||
CreateOrUpdate(context.Context, *Farm) error
|
||||
Delete(context.Context, string) error
|
||||
SetEventPublisher(EventPublisher)
|
||||
|
||||
@ -45,6 +45,7 @@ func (p *Plant) Validate() error {
|
||||
type PlantRepository interface {
|
||||
GetByUUID(context.Context, string) (Plant, error)
|
||||
GetAll(context.Context) ([]Plant, error)
|
||||
GetByName(context.Context, string) (Plant, error)
|
||||
Create(context.Context, *Plant) error
|
||||
Update(context.Context, *Plant) error
|
||||
Delete(context.Context, string) error
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// backend/internal/event/projection.go
|
||||
package event
|
||||
|
||||
import (
|
||||
@ -35,11 +34,10 @@ func NewFarmAnalyticsProjection(
|
||||
|
||||
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"
|
||||
"farm.created", "farm.updated", "farm.deleted",
|
||||
"weather.updated",
|
||||
"cropland.created", "cropland.updated", "cropland.deleted",
|
||||
"inventory.item.created", "inventory.item.updated", "inventory.item.deleted",
|
||||
}
|
||||
|
||||
p.logger.Info("FarmAnalyticsProjection starting, subscribing to events", "types", eventTypes)
|
||||
@ -49,8 +47,6 @@ func (p *FarmAnalyticsProjection) Start(ctx context.Context) error {
|
||||
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)
|
||||
}
|
||||
@ -65,33 +61,30 @@ func (p *FarmAnalyticsProjection) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // 10-second timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
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
|
||||
farmID := event.AggregateID
|
||||
|
||||
// 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 == "" {
|
||||
// Try to get farmID from payload if AggregateID is empty or potentially not the farmID (e.g., user events)
|
||||
if farmID == "" || event.Type == "inventory.item.created" || event.Type == "inventory.item.updated" || event.Type == "inventory.item.deleted" || event.Type == "cropland.created" || event.Type == "cropland.updated" || event.Type == "cropland.deleted" {
|
||||
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
|
||||
} else if event.Type != "farm.deleted" && event.Type != "farm.created" {
|
||||
p.logger.Warn("Could not determine farm_id from event payload or AggregateID", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID)
|
||||
return nil
|
||||
}
|
||||
} else if event.Type != "farm.deleted" && event.Type != "farm.created" {
|
||||
p.logger.Error("Event payload is not a map, cannot extract farm_id", "event_type", event.Type, "event_id", event.ID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if farmID == "" && event.Type != "farm.deleted" { // farm.deleted uses AggregateID which is the farmID being deleted
|
||||
if farmID == "" && event.Type != "farm.deleted" {
|
||||
p.logger.Error("Cannot process event, missing farm_id", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID)
|
||||
return nil
|
||||
}
|
||||
@ -99,22 +92,21 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
|
||||
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
|
||||
jsonData, _ := json.Marshal(event.Payload)
|
||||
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
|
||||
}
|
||||
|
||||
p.logger.Info("Processing farm event", "event_type", event.Type, "farm_id", farmData.UUID, "owner_id", farmData.OwnerID)
|
||||
err = p.repository.CreateOrUpdateFarmBaseData(ctx, &farmData)
|
||||
|
||||
case "farm.deleted":
|
||||
farmID = event.AggregateID // Use AggregateID directly for delete
|
||||
farmID = event.AggregateID
|
||||
if farmID == "" {
|
||||
p.logger.Error("Cannot process farm.deleted event, missing farm_id in AggregateID", "event_id", event.ID)
|
||||
return nil
|
||||
@ -122,12 +114,11 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
|
||||
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
|
||||
return nil
|
||||
}
|
||||
err = p.repository.UpdateFarmAnalyticsWeather(ctx, farmID, &weatherData)
|
||||
|
||||
@ -146,8 +137,6 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
|
||||
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 {
|
||||
@ -162,7 +151,6 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -124,20 +124,19 @@ func (p *postgresCroplandRepository) CreateOrUpdate(ctx context.Context, c *doma
|
||||
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,
|
||||
"uuid": c.UUID,
|
||||
"name": c.Name,
|
||||
"status": c.Status,
|
||||
"priority": c.Priority,
|
||||
"landSize": c.LandSize,
|
||||
"growthStage": c.GrowthStage,
|
||||
"plantId": c.PlantID,
|
||||
"farmId": c.FarmID,
|
||||
"geoFeature": geoFeatureMap,
|
||||
"createdAt": c.CreatedAt,
|
||||
"updatedAt": c.UpdatedAt,
|
||||
"event_type": eventType,
|
||||
}
|
||||
|
||||
event := domain.Event{
|
||||
|
||||
@ -2,11 +2,13 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type postgresFarmRepository struct {
|
||||
@ -94,11 +96,52 @@ func (p *postgresFarmRepository) fetchCroplandsByFarmIDs(ctx context.Context, fa
|
||||
return croplandsByFarmID, nil
|
||||
}
|
||||
|
||||
func (p *postgresFarmRepository) GetAll(ctx context.Context) ([]domain.Farm, error) {
|
||||
// Query to select all farms, ordered by creation date for consistency
|
||||
query := `
|
||||
SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id
|
||||
FROM farms
|
||||
ORDER BY created_at DESC`
|
||||
|
||||
// Use the existing fetch method without specific arguments for filtering
|
||||
farms, err := p.fetch(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(farms) == 0 {
|
||||
return []domain.Farm{}, nil // Return empty slice, not nil
|
||||
}
|
||||
|
||||
// --- Fetch associated crops (optional but good for consistency) ---
|
||||
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 {
|
||||
// Log the warning but return the farms fetched so far
|
||||
// Depending on requirements, you might want to return the error instead
|
||||
println("Warning: Failed to fetch associated croplands during GetAll:", err.Error())
|
||||
} else {
|
||||
for farmID, croplands := range croplandsByFarmID {
|
||||
if farm, ok := farmMap[farmID]; ok {
|
||||
farm.Crops = croplands
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- End Fetch associated crops ---
|
||||
|
||||
return farms, 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`
|
||||
SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id
|
||||
FROM farms
|
||||
WHERE uuid = $1`
|
||||
var f domain.Farm
|
||||
err := p.conn.QueryRow(ctx, query, farmId).Scan(
|
||||
&f.UUID,
|
||||
@ -112,8 +155,21 @@ func (p *postgresFarmRepository) GetByID(ctx context.Context, farmId string) (*d
|
||||
&f.OwnerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if errors.Is(err, pgx.ErrNoRows) { // Check for pgx specific error
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, err // Return other errors
|
||||
}
|
||||
|
||||
// Fetch associated crops (optional, depends if GetByID needs them)
|
||||
cropsMap, err := p.fetchCroplandsByFarmIDs(ctx, []string{f.UUID})
|
||||
if err != nil {
|
||||
println("Warning: Failed to fetch croplands for GetByID:", err.Error())
|
||||
// Decide whether to return the farm without crops or return the error
|
||||
} else if crops, ok := cropsMap[f.UUID]; ok {
|
||||
f.Crops = crops
|
||||
}
|
||||
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
@ -192,14 +248,16 @@ func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.F
|
||||
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,
|
||||
"uuid": f.UUID,
|
||||
"name": f.Name,
|
||||
"lat": f.Lat,
|
||||
"lon": f.Lon,
|
||||
"location": map[string]float64{"lat": f.Lat, "lon": f.Lon},
|
||||
"farmType": f.FarmType,
|
||||
"totalSize": f.TotalSize,
|
||||
"ownerId": f.OwnerID,
|
||||
"createdAt": f.CreatedAt,
|
||||
"updatedAt": f.UpdatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@ func (r *postgresFarmAnalyticsRepository) GetFarmAnalytics(ctx context.Context,
|
||||
&analytics.OwnerID,
|
||||
&farmType,
|
||||
&totalSize,
|
||||
&analytics.Latitude, // Scan directly into the struct fields
|
||||
&analytics.Latitude,
|
||||
&analytics.Longitude,
|
||||
&weatherJSON,
|
||||
&inventoryJSON,
|
||||
@ -228,16 +228,16 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context,
|
||||
|
||||
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)
|
||||
INSERT INTO farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude, analytics_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;`
|
||||
latitude = EXCLUDED.latitude,
|
||||
longitude = EXCLUDED.longitude,
|
||||
analytics_last_updated = EXCLUDED.analytics_last_updated;`
|
||||
|
||||
_, err := r.conn.Exec(ctx, query,
|
||||
farm.UUID,
|
||||
@ -259,158 +259,100 @@ func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context
|
||||
|
||||
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *domain.WeatherData) error {
|
||||
if weatherData == nil {
|
||||
return fmt.Errorf("weather data cannot be nil")
|
||||
return errors.New("weather data cannot be nil for update")
|
||||
}
|
||||
|
||||
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;`
|
||||
UPDATE public.farm_analytics SET
|
||||
weather_temp_celsius = $2,
|
||||
weather_humidity = $3,
|
||||
weather_description = $4,
|
||||
weather_icon = $5,
|
||||
weather_wind_speed = $6,
|
||||
weather_rain_1h = $7,
|
||||
weather_observed_at = $8,
|
||||
weather_last_updated = NOW(), -- Use current time for the update time
|
||||
analytics_last_updated = NOW()
|
||||
WHERE farm_id = $1`
|
||||
|
||||
cmdTag, err := r.conn.Exec(ctx, query, weatherJSON, time.Now().UTC(), farmID)
|
||||
_, err := r.conn.Exec(ctx, query,
|
||||
farmID,
|
||||
weatherData.TempCelsius,
|
||||
weatherData.Humidity,
|
||||
weatherData.Description,
|
||||
weatherData.Icon,
|
||||
weatherData.WindSpeed,
|
||||
weatherData.RainVolume1h,
|
||||
weatherData.ObservedAt,
|
||||
)
|
||||
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)
|
||||
r.logger.Error("Error updating farm weather analytics", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("failed to update weather analytics for farm %s: %w", farmID, 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)
|
||||
r.logger.Debug("Updated farm weather analytics", "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 {
|
||||
countQuery := `
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE lower(status) = 'growing')
|
||||
FROM public.croplands
|
||||
WHERE farm_id = $1
|
||||
`
|
||||
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)
|
||||
err := r.conn.QueryRow(ctx, countQuery, 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)
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
r.logger.Error("Error calculating crop counts", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("failed to calculate crop stats for farm %s: %w", farmID, 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;`
|
||||
UPDATE public.farm_analytics SET
|
||||
crop_total_count = $2,
|
||||
crop_growing_count = $3,
|
||||
crop_last_updated = NOW(),
|
||||
analytics_last_updated = NOW()
|
||||
WHERE farm_id = $1`
|
||||
|
||||
cmdTag, err := r.conn.Exec(ctx, updateQuery, cropJSON, time.Now().UTC(), farmID)
|
||||
cmdTag, err := r.conn.Exec(ctx, updateQuery, farmID, totalCount, growingCount)
|
||||
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)
|
||||
r.logger.Error("Error updating farm crop stats", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("failed to update crop stats for farm %s: %w", farmID, 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)
|
||||
// Optionally, create the base record here if it should always exist
|
||||
return r.CreateOrUpdateFarmBaseData(ctx, &domain.Farm{UUID: farmID /* Fetch other details */})
|
||||
}
|
||||
|
||||
r.logger.Debug("Updated farm 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;`
|
||||
UPDATE public.farm_analytics SET
|
||||
-- inventory_total_items = (SELECT COUNT(*) FROM ... WHERE farm_id = $1), -- Example future logic
|
||||
-- inventory_low_stock_count = (SELECT COUNT(*) FROM ... WHERE farm_id = $1 AND status = 'low'), -- Example
|
||||
inventory_last_updated = NOW(),
|
||||
analytics_last_updated = NOW()
|
||||
WHERE farm_id = $1`
|
||||
|
||||
err = r.conn.QueryRow(ctx, query, ownerID).Scan(&totalItems, &lowStockCount, &lastUpdated)
|
||||
cmdTag, err := r.conn.Exec(ctx, query, farmID)
|
||||
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)
|
||||
r.logger.Error("Error touching inventory timestamp in farm analytics", "farm_id", farmID, "error", err)
|
||||
return fmt.Errorf("failed to update inventory stats timestamp for farm %s: %w", farmID, 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)
|
||||
r.logger.Warn("No farm analytics record found to update inventory timestamp", "farm_id", farmID)
|
||||
}
|
||||
|
||||
r.logger.Debug("Updated farm inventory timestamp", "farm_id", farmID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -432,20 +374,18 @@ func (r *postgresFarmAnalyticsRepository) DeleteFarmAnalytics(ctx context.Contex
|
||||
|
||||
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;`
|
||||
UPDATE public.farm_analytics SET
|
||||
overall_status = $2,
|
||||
analytics_last_updated = NOW()
|
||||
WHERE farm_id = $1`
|
||||
|
||||
cmdTag, err := r.conn.Exec(ctx, query, status, time.Now().UTC(), farmID)
|
||||
cmdTag, err := r.conn.Exec(ctx, query, farmID, status)
|
||||
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)
|
||||
r.logger.Error("Error updating farm overall status", "farm_id", farmID, "status", status, "error", err)
|
||||
return fmt.Errorf("failed to update overall status for farm %s: %w", farmID, 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
|
||||
|
||||
@ -268,15 +268,15 @@ func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *
|
||||
}
|
||||
|
||||
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,
|
||||
"id": item.ID,
|
||||
"userId": item.UserID, // Include user ID for potential farm lookup in projection
|
||||
"name": item.Name,
|
||||
"categoryId": item.CategoryID,
|
||||
"quantity": item.Quantity,
|
||||
"unitId": item.UnitID,
|
||||
"statusId": item.StatusID,
|
||||
"dateAdded": item.DateAdded,
|
||||
"updatedAt": item.UpdatedAt,
|
||||
// NO farm_id easily available here without extra lookup
|
||||
}
|
||||
|
||||
|
||||
@ -51,6 +51,15 @@ func (p *postgresPlantRepository) GetByUUID(ctx context.Context, uuid string) (d
|
||||
return plants[0], nil
|
||||
}
|
||||
|
||||
func (p *postgresPlantRepository) GetByName(ctx context.Context, name string) (domain.Plant, error) {
|
||||
query := `SELECT * FROM plants WHERE name = $1`
|
||||
plants, err := p.fetch(ctx, query, name)
|
||||
if err != nil || len(plants) == 0 {
|
||||
return domain.Plant{}, domain.ErrNotFound
|
||||
}
|
||||
return plants[0], nil
|
||||
}
|
||||
|
||||
func (p *postgresPlantRepository) GetAll(ctx context.Context) ([]domain.Plant, error) {
|
||||
query := `SELECT * FROM plants`
|
||||
return p.fetch(ctx, query)
|
||||
|
||||
@ -7,41 +7,31 @@ import (
|
||||
"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
|
||||
return "n/a"
|
||||
default:
|
||||
// Slightly randomize for demo purposes
|
||||
if rand.Intn(10) < 2 { // 20% chance of warning even if status is 'growing'
|
||||
// 20% chance of warning even if status is 'growing'
|
||||
if rand.Intn(10) < 2 {
|
||||
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
|
||||
nextDueDate := time.Now().Add(24 * time.Hour)
|
||||
|
||||
switch growthStage {
|
||||
case "Planned", "Planting":
|
||||
@ -58,30 +48,27 @@ func (s *AnalyticsService) SuggestNextAction(growthStage string, lastUpdated tim
|
||||
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
|
||||
nextDueDate = time.Now().Add(7 * 24 * time.Hour)
|
||||
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
|
||||
// Only suggest if due date is >1hr after last update
|
||||
if nextDueDate.After(lastUpdated.Add(1 * time.Hour)) {
|
||||
return &nextActionStr, &nextDueDate
|
||||
}
|
||||
|
||||
return nil, nil // No immediate action needed or suggestion is old
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// 70% chance of having dummy data
|
||||
if rand.Intn(10) < 7 {
|
||||
n := float64(50 + rand.Intn(40)) // 50-89
|
||||
p := float64(40 + rand.Intn(40)) // 40-79
|
||||
k := float64(45 + rand.Intn(40)) // 45-84
|
||||
@ -95,26 +82,20 @@ func (s *AnalyticsService) GetNutrientLevels(cropID string) *struct {
|
||||
Potassium: &k,
|
||||
}
|
||||
}
|
||||
return nil // Simulate data not available
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Provide dummy values only if data is missing
|
||||
if temp == nil {
|
||||
t := float64(18 + rand.Intn(15)) // 18-32 C
|
||||
temp = &t
|
||||
@ -128,7 +109,6 @@ func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalyt
|
||||
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
|
||||
@ -144,5 +124,5 @@ func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalyt
|
||||
soilMoisture = &sm
|
||||
}
|
||||
|
||||
return // Named return values
|
||||
return
|
||||
}
|
||||
|
||||
445
backend/internal/services/chat_service.go
Normal file
445
backend/internal/services/chat_service.go
Normal file
@ -0,0 +1,445 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/forfarm/backend/internal/config"
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
"github.com/google/generative-ai-go/genai"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type ChatService struct {
|
||||
client *genai.Client
|
||||
logger *slog.Logger
|
||||
analyticsRepo domain.AnalyticsRepository
|
||||
farmRepo domain.FarmRepository
|
||||
cropRepo domain.CroplandRepository
|
||||
inventoryRepo domain.InventoryRepository
|
||||
plantRepo domain.PlantRepository
|
||||
}
|
||||
|
||||
func NewChatService(
|
||||
logger *slog.Logger,
|
||||
analyticsRepo domain.AnalyticsRepository,
|
||||
farmRepo domain.FarmRepository,
|
||||
cropRepo domain.CroplandRepository,
|
||||
inventoryRepo domain.InventoryRepository,
|
||||
plantRepo domain.PlantRepository,
|
||||
) (*ChatService, error) {
|
||||
if config.GEMINI_API_KEY == "" {
|
||||
logger.Warn("GEMINI_API_KEY not set, ChatService will not function.")
|
||||
return &ChatService{client: nil, logger: logger}, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
client, err := genai.NewClient(ctx, option.WithAPIKey(config.GEMINI_API_KEY))
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Gemini client", "error", err)
|
||||
return nil, fmt.Errorf("error creating genai client: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Gemini client initialized successfully")
|
||||
return &ChatService{
|
||||
client: client,
|
||||
logger: logger,
|
||||
analyticsRepo: analyticsRepo,
|
||||
farmRepo: farmRepo,
|
||||
cropRepo: cropRepo,
|
||||
inventoryRepo: inventoryRepo,
|
||||
plantRepo: plantRepo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type GenerateResponseInput struct {
|
||||
UserID string
|
||||
Message string
|
||||
FarmID string
|
||||
CropID string
|
||||
History []*genai.Content
|
||||
}
|
||||
|
||||
// --- Context Building Helpers ---
|
||||
|
||||
func (s *ChatService) buildCropContextString(ctx context.Context, cropID, userID string) (string, error) {
|
||||
var contextBuilder strings.Builder
|
||||
contextBuilder.WriteString("## Current Crop & Plant Context ##\n")
|
||||
|
||||
cropAnalytics, err := s.analyticsRepo.GetCropAnalytics(ctx, cropID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
s.logger.Warn("Crop analytics not found for context", "cropId", cropID)
|
||||
return "", fmt.Errorf("crop not found")
|
||||
}
|
||||
s.logger.Error("Failed to fetch crop analytics context", "cropId", cropID, "error", err)
|
||||
contextBuilder.WriteString(fmt.Sprintf("Error fetching crop details for ID %s.\n", cropID))
|
||||
return "", fmt.Errorf("failed to fetch crop details")
|
||||
}
|
||||
|
||||
farm, err := s.farmRepo.GetByID(ctx, cropAnalytics.FarmID)
|
||||
if err != nil || farm.OwnerID != userID {
|
||||
s.logger.Warn("Ownership check failed for crop context", "cropId", cropID, "farmId", cropAnalytics.FarmID, "userId", userID)
|
||||
return "", fmt.Errorf("unauthorized access to crop data")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&contextBuilder, "Crop Name: %s (ID: %s)\n", cropAnalytics.CropName, cropAnalytics.CropID)
|
||||
fmt.Fprintf(&contextBuilder, "Farm: %s (ID: %s)\n", farm.Name, cropAnalytics.FarmID)
|
||||
fmt.Fprintf(&contextBuilder, "Plant: %s (Variety: %s)\n", cropAnalytics.PlantName, safeString(cropAnalytics.Variety))
|
||||
fmt.Fprintf(&contextBuilder, "Status: %s\n", cropAnalytics.CurrentStatus)
|
||||
fmt.Fprintf(&contextBuilder, "Growth Stage: %s\n", cropAnalytics.GrowthStage)
|
||||
fmt.Fprintf(&contextBuilder, "Land Size: %.2f ha\n", cropAnalytics.LandSize)
|
||||
fmt.Fprintf(&contextBuilder, "Growth Progress: %d%%\n", cropAnalytics.GrowthProgress)
|
||||
if cropAnalytics.PlantHealth != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Health Status: %s\n", *cropAnalytics.PlantHealth)
|
||||
}
|
||||
if cropAnalytics.Temperature != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Temperature: %.1f°C\n", *cropAnalytics.Temperature)
|
||||
}
|
||||
if cropAnalytics.Humidity != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Humidity: %.0f%%\n", *cropAnalytics.Humidity)
|
||||
}
|
||||
if cropAnalytics.SoilMoisture != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Soil Moisture: %.0f%%\n", *cropAnalytics.SoilMoisture)
|
||||
}
|
||||
if cropAnalytics.Rainfall != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Rainfall (1h): %.1f mm\n", *cropAnalytics.Rainfall)
|
||||
}
|
||||
if cropAnalytics.WindSpeed != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Wind Speed: %.1f m/s\n", *cropAnalytics.WindSpeed)
|
||||
}
|
||||
if cropAnalytics.Sunlight != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Sunlight Exposure: %.0f%%\n", *cropAnalytics.Sunlight)
|
||||
}
|
||||
if cropAnalytics.NutrientLevels != nil {
|
||||
contextBuilder.WriteString("Nutrients: ")
|
||||
nutrients := []string{}
|
||||
if cropAnalytics.NutrientLevels.Nitrogen != nil {
|
||||
nutrients = append(nutrients, fmt.Sprintf("N=%.0f%%", *cropAnalytics.NutrientLevels.Nitrogen))
|
||||
}
|
||||
if cropAnalytics.NutrientLevels.Phosphorus != nil {
|
||||
nutrients = append(nutrients, fmt.Sprintf("P=%.0f%%", *cropAnalytics.NutrientLevels.Phosphorus))
|
||||
}
|
||||
if cropAnalytics.NutrientLevels.Potassium != nil {
|
||||
nutrients = append(nutrients, fmt.Sprintf("K=%.0f%%", *cropAnalytics.NutrientLevels.Potassium))
|
||||
}
|
||||
if len(nutrients) > 0 {
|
||||
contextBuilder.WriteString(strings.Join(nutrients, ", "))
|
||||
} else {
|
||||
contextBuilder.WriteString("Not Available")
|
||||
}
|
||||
contextBuilder.WriteString("\n")
|
||||
}
|
||||
if cropAnalytics.NextAction != nil {
|
||||
dueStr := ""
|
||||
if cropAnalytics.NextActionDue != nil {
|
||||
dueStr = fmt.Sprintf(" (Due: %s)", cropAnalytics.NextActionDue.Format(time.RFC1123))
|
||||
}
|
||||
fmt.Fprintf(&contextBuilder, "Suggested Next Action: %s%s\n", *cropAnalytics.NextAction, dueStr)
|
||||
}
|
||||
contextBuilder.WriteString("\n")
|
||||
|
||||
contextBuilder.WriteString("Plant Details:\n")
|
||||
plant, err := s.plantRepo.GetByName(ctx, cropAnalytics.PlantName)
|
||||
if err != nil {
|
||||
s.logger.Warn("Could not fetch plant details for context", "plantId", cropAnalytics.PlantName, "error", err)
|
||||
fmt.Fprintf(&contextBuilder, " - Could not retrieve plant details.\n")
|
||||
} else {
|
||||
fmt.Fprintf(&contextBuilder, " - Type: %s (Variety: %s)\n", plant.Name, safeString(plant.Variety))
|
||||
if plant.DaysToMaturity != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Days to Maturity: ~%d\n", *plant.DaysToMaturity)
|
||||
}
|
||||
if plant.OptimalTemp != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Optimal Temp: %.1f°C\n", *plant.OptimalTemp)
|
||||
}
|
||||
if plant.WaterNeeds != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Water Needs: %.1f (units unspecified)\n", *plant.WaterNeeds)
|
||||
}
|
||||
if plant.PHValue != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Soil pH: %.1f\n", *plant.PHValue)
|
||||
}
|
||||
if plant.RowSpacing != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Row Spacing: %.1f (units unspecified)\n", *plant.RowSpacing)
|
||||
}
|
||||
if plant.PlantingDepth != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Planting Depth: %.1f (units unspecified)\n", *plant.PlantingDepth)
|
||||
}
|
||||
}
|
||||
|
||||
contextBuilder.WriteString("\n")
|
||||
|
||||
inventoryContext, _ := s.buildInventoryContextString(ctx, userID)
|
||||
contextBuilder.WriteString(inventoryContext)
|
||||
|
||||
return contextBuilder.String(), nil
|
||||
}
|
||||
|
||||
func (s *ChatService) buildFarmContextString(ctx context.Context, farmID, userID string) (string, error) {
|
||||
var contextBuilder strings.Builder
|
||||
contextBuilder.WriteString("## Current Farm Context ##\n")
|
||||
|
||||
farmAnalytics, err := s.analyticsRepo.GetFarmAnalytics(ctx, farmID)
|
||||
if err != nil || farmAnalytics.OwnerID != userID {
|
||||
if errors.Is(err, domain.ErrNotFound) || farmAnalytics == nil || farmAnalytics.OwnerID != userID {
|
||||
s.logger.Warn("Farm analytics not found or ownership mismatch for context", "farmId", farmID, "userId", userID)
|
||||
return "", fmt.Errorf("farm not found or access denied")
|
||||
}
|
||||
s.logger.Error("Failed to fetch farm analytics context", "farmId", farmID, "error", err)
|
||||
return "", fmt.Errorf("failed to fetch farm details")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&contextBuilder, "Farm Name: %s (ID: %s)\n", farmAnalytics.FarmName, farmAnalytics.FarmID)
|
||||
if farmAnalytics.FarmType != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Type: %s\n", *farmAnalytics.FarmType)
|
||||
}
|
||||
if farmAnalytics.TotalSize != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Size: %s\n", *farmAnalytics.TotalSize)
|
||||
}
|
||||
fmt.Fprintf(&contextBuilder, "Location: Lat %.4f, Lon %.4f\n", farmAnalytics.Latitude, farmAnalytics.Longitude)
|
||||
if farmAnalytics.OverallStatus != nil {
|
||||
fmt.Fprintf(&contextBuilder, "Overall Status: %s\n", *farmAnalytics.OverallStatus)
|
||||
}
|
||||
if farmAnalytics.Weather != nil {
|
||||
contextBuilder.WriteString("Weather:\n")
|
||||
if farmAnalytics.Weather.TempCelsius != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Temp: %.1f°C\n", *farmAnalytics.Weather.TempCelsius)
|
||||
}
|
||||
if farmAnalytics.Weather.Humidity != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Humidity: %.0f%%\n", *farmAnalytics.Weather.Humidity)
|
||||
}
|
||||
if farmAnalytics.Weather.Description != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Condition: %s\n", *farmAnalytics.Weather.Description)
|
||||
}
|
||||
if farmAnalytics.Weather.WindSpeed != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Wind: %.1f m/s\n", *farmAnalytics.Weather.WindSpeed)
|
||||
}
|
||||
if farmAnalytics.Weather.RainVolume1h != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Rain (1h): %.1f mm\n", *farmAnalytics.Weather.RainVolume1h)
|
||||
}
|
||||
if farmAnalytics.Weather.WeatherLastUpdated != nil {
|
||||
fmt.Fprintf(&contextBuilder, " - Last Updated: %s\n", farmAnalytics.Weather.WeatherLastUpdated.Format(time.RFC1123))
|
||||
}
|
||||
}
|
||||
|
||||
crops, err := s.cropRepo.GetByFarmID(ctx, farmID)
|
||||
if err == nil && len(crops) > 0 {
|
||||
contextBuilder.WriteString("Crops on Farm:\n")
|
||||
for i, crop := range crops {
|
||||
if i >= 5 {
|
||||
fmt.Fprintf(&contextBuilder, " - ... and %d more\n", len(crops)-5)
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&contextBuilder, " - %s (Status: %s, Stage: %s)\n", crop.Name, crop.Status, crop.GrowthStage)
|
||||
}
|
||||
} else if err != nil {
|
||||
s.logger.Warn("Failed to fetch crops for farm context", "farmId", farmID, "error", err)
|
||||
}
|
||||
|
||||
contextBuilder.WriteString("\n")
|
||||
inventoryContext, _ := s.buildInventoryContextString(ctx, userID)
|
||||
contextBuilder.WriteString(inventoryContext)
|
||||
|
||||
return contextBuilder.String(), nil
|
||||
}
|
||||
|
||||
func (s *ChatService) buildInventoryContextString(ctx context.Context, userID string) (string, error) {
|
||||
var contextBuilder strings.Builder
|
||||
contextBuilder.WriteString("## Inventory Summary ##\n")
|
||||
|
||||
filter := domain.InventoryFilter{UserID: userID}
|
||||
sort := domain.InventorySort{Field: "name", Direction: "asc"}
|
||||
items, err := s.inventoryRepo.GetByUserID(ctx, userID, filter, sort)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to fetch inventory for context", "userId", userID, "error", err)
|
||||
fmt.Fprintf(&contextBuilder, "Could not retrieve inventory details.\n")
|
||||
return contextBuilder.String(), err
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintf(&contextBuilder, "No inventory items found.\n")
|
||||
return contextBuilder.String(), nil
|
||||
}
|
||||
|
||||
lowStockCount := 0
|
||||
fmt.Fprintf(&contextBuilder, "Items (%d total):\n", len(items))
|
||||
limit := 10
|
||||
for i, item := range items {
|
||||
if i >= limit {
|
||||
fmt.Fprintf(&contextBuilder, "- ... and %d more\n", len(items)-limit)
|
||||
break
|
||||
}
|
||||
statusName := item.Status.Name
|
||||
if statusName == "" && item.StatusID != 0 {
|
||||
statusName = fmt.Sprintf("StatusID %d", item.StatusID)
|
||||
}
|
||||
unitName := item.Unit.Name
|
||||
if unitName == "" && item.UnitID != 0 {
|
||||
unitName = fmt.Sprintf("UnitID %d", item.UnitID)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&contextBuilder, "- %s: %.2f %s (Status: %s)\n", item.Name, item.Quantity, unitName, statusName)
|
||||
if strings.Contains(strings.ToLower(statusName), "low") {
|
||||
lowStockCount++
|
||||
}
|
||||
}
|
||||
if lowStockCount > 0 {
|
||||
fmt.Fprintf(&contextBuilder, "Note: %d item(s) are low on stock.\n", lowStockCount)
|
||||
}
|
||||
|
||||
return contextBuilder.String(), nil
|
||||
}
|
||||
|
||||
func (s *ChatService) buildGeneralContextString(ctx context.Context, userID string) (string, error) {
|
||||
var contextBuilder strings.Builder
|
||||
contextBuilder.WriteString("## General Farming Context ##\n")
|
||||
|
||||
farms, err := s.farmRepo.GetByOwnerID(ctx, userID)
|
||||
if err == nil && len(farms) > 0 {
|
||||
contextBuilder.WriteString("Your Farms:\n")
|
||||
for i, farm := range farms {
|
||||
if i >= 5 {
|
||||
fmt.Fprintf(&contextBuilder, "- ... and %d more\n", len(farms)-5)
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&contextBuilder, "- %s (Type: %s, Size: %s)\n", farm.Name, farm.FarmType, farm.TotalSize)
|
||||
}
|
||||
} else if err != nil {
|
||||
s.logger.Warn("Failed to fetch farms for general context", "userId", userID, "error", err)
|
||||
} else {
|
||||
contextBuilder.WriteString("No farms found for your account.\n")
|
||||
}
|
||||
contextBuilder.WriteString("\n")
|
||||
|
||||
inventoryContext, _ := s.buildInventoryContextString(ctx, userID)
|
||||
contextBuilder.WriteString(inventoryContext)
|
||||
|
||||
return contextBuilder.String(), nil
|
||||
}
|
||||
|
||||
func (s *ChatService) GenerateResponse(ctx context.Context, input GenerateResponseInput) (string, error) {
|
||||
var contextString string
|
||||
var contextErr error
|
||||
startTime := time.Now()
|
||||
|
||||
if input.CropID != "" {
|
||||
s.logger.Debug("Building context for CROP", "cropId", input.CropID)
|
||||
contextString, contextErr = s.buildCropContextString(ctx, input.CropID, input.UserID)
|
||||
} else if input.FarmID != "" {
|
||||
s.logger.Debug("Building context for FARM", "farmId", input.FarmID)
|
||||
contextString, contextErr = s.buildFarmContextString(ctx, input.FarmID, input.UserID)
|
||||
} else {
|
||||
s.logger.Debug("Building GENERAL context", "userId", input.UserID)
|
||||
contextString, contextErr = s.buildGeneralContextString(ctx, input.UserID)
|
||||
}
|
||||
|
||||
newsContext, _ := s.retrieveDummyNews(ctx)
|
||||
weatherOutlook, _ := s.retrieveDummyWeatherOutlook(ctx, input.FarmID)
|
||||
|
||||
fullContext := strings.Builder{}
|
||||
fullContext.WriteString(contextString)
|
||||
if contextErr != nil {
|
||||
s.logger.Warn("Error building primary context string", "error", contextErr, "userId", input.UserID, "farmId", input.FarmID, "cropId", input.CropID)
|
||||
fullContext.WriteString(fmt.Sprintf("Warning: Could not retrieve specific data (%s).\n\n", contextErr))
|
||||
}
|
||||
fullContext.WriteString(newsContext)
|
||||
fullContext.WriteString(weatherOutlook)
|
||||
|
||||
contextDuration := time.Since(startTime)
|
||||
s.logger.Debug("Context retrieval duration", "duration", contextDuration)
|
||||
|
||||
model := s.client.GenerativeModel("gemini-1.5-flash")
|
||||
systemInstruction := `You are ForFarm Assistant, an expert AI specialized in agriculture and farming practices.
|
||||
Your goal is to provide helpful, accurate, and concise advice to farmers using the ForFarm platform.
|
||||
Use the provided context data about the user's specific farm, crops, or inventory when available to tailor your response.
|
||||
If context is provided, prioritize answering based on that context.
|
||||
If no specific context is available or relevant, provide general best-practice farming advice.
|
||||
Focus on actionable recommendations where appropriate.
|
||||
Keep responses focused on farming, agriculture, crop management, pest control, soil health, weather impacts, and inventory management.`
|
||||
model.SystemInstruction = &genai.Content{Parts: []genai.Part{genai.Text(systemInstruction)}}
|
||||
fullPrompt := fmt.Sprintf("%s\nUser Question: %s", strings.TrimSpace(fullContext.String()), input.Message)
|
||||
|
||||
session := model.StartChat()
|
||||
session.History = input.History
|
||||
|
||||
s.logger.Info("Sending message to LLM", "userId", input.UserID, "historyLength", len(session.History), "contextLength", len(fullContext.String()))
|
||||
|
||||
resp, err := session.SendMessage(ctx, genai.Text(fullPrompt))
|
||||
llmDuration := time.Since(startTime) - contextDuration
|
||||
s.logger.Debug("LLM response duration", "duration", llmDuration)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("Error sending message to Gemini", "error", err)
|
||||
return "Sorry, I encountered an error while generating a response.", fmt.Errorf("LLM communication failed: %w", err)
|
||||
}
|
||||
|
||||
var responseText strings.Builder
|
||||
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
|
||||
for _, part := range resp.Candidates[0].Content.Parts {
|
||||
if txt, ok := part.(genai.Text); ok {
|
||||
responseText.WriteString(string(txt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if responseText.Len() == 0 {
|
||||
s.logger.Warn("Received no valid text content from Gemini", "finishReason", resp.Candidates[0].FinishReason)
|
||||
if resp.Candidates[0].FinishReason != genai.FinishReasonStop {
|
||||
return fmt.Sprintf("My response generation was interrupted (%s). Could you please try rephrasing?", resp.Candidates[0].FinishReason), nil
|
||||
}
|
||||
return "I apologize, I couldn't generate a response for that request.", nil
|
||||
}
|
||||
|
||||
s.logger.Info("Successfully generated chat response", "userId", input.UserID, "responseLength", responseText.Len())
|
||||
return responseText.String(), nil
|
||||
}
|
||||
|
||||
// Simulates fetching news with artificial delay and random selection
|
||||
func (s *ChatService) retrieveDummyNews(ctx context.Context) (string, error) {
|
||||
s.logger.Debug("Retrieving dummy news context")
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if rand.Intn(10) > 2 {
|
||||
newsItems := []string{
|
||||
"Global wheat prices show slight increase.",
|
||||
"New organic pest control method using beneficial nematodes gaining traction.",
|
||||
"Research highlights drought-resistant corn variety performance.",
|
||||
"Government announces new subsidies for sustainable farming practices.",
|
||||
}
|
||||
return fmt.Sprintf("## Recent Agricultural News ##\n- %s\n\n", newsItems[rand.Intn(len(newsItems))]), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Simulates fetching weather forecast with artificial delay and random selection
|
||||
func (s *ChatService) retrieveDummyWeatherOutlook(ctx context.Context, farmID string) (string, error) {
|
||||
s.logger.Debug("Retrieving dummy weather outlook context", "farmId", farmID)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
forecasts := []string{
|
||||
"Clear skies expected for the next 3 days.",
|
||||
"Chance of scattered showers tomorrow afternoon.",
|
||||
"Temperature expected to rise towards the weekend.",
|
||||
"Slightly higher winds predicted for Thursday.",
|
||||
}
|
||||
return fmt.Sprintf("## Weather Outlook ##\n- %s\n\n", forecasts[rand.Intn(len(forecasts))]), nil
|
||||
}
|
||||
|
||||
func safeString(s *string) string {
|
||||
if s == nil {
|
||||
return "N/A"
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func (s *ChatService) Close() {
|
||||
if s.client != nil {
|
||||
if err := s.client.Close(); err != nil {
|
||||
s.logger.Error("Failed to close Gemini client", "error", err)
|
||||
} else {
|
||||
s.logger.Info("Gemini client closed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// backend/internal/services/weather/openweathermap_fetcher.go
|
||||
package weather
|
||||
|
||||
import (
|
||||
@ -14,45 +13,57 @@ import (
|
||||
"github.com/forfarm/backend/internal/domain"
|
||||
)
|
||||
|
||||
const openWeatherMapOneCallAPIURL = "https://api.openweathermap.org/data/3.0/onecall"
|
||||
const openWeatherMapCurrentAPIURL = "https://api.openweathermap.org/data/2.5/weather"
|
||||
|
||||
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 openWeatherMapCurrentResponse struct {
|
||||
Coord struct {
|
||||
Lon float64 `json:"lon"`
|
||||
Lat float64 `json:"lat"`
|
||||
} `json:"coord"`
|
||||
Weather []struct {
|
||||
ID int `json:"id"`
|
||||
Main string `json:"main"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
} `json:"weather"`
|
||||
Base string `json:"base"`
|
||||
Main *struct {
|
||||
Temp float64 `json:"temp"`
|
||||
FeelsLike float64 `json:"feels_like"`
|
||||
TempMin float64 `json:"temp_min"`
|
||||
TempMax float64 `json:"temp_max"`
|
||||
Pressure int `json:"pressure"`
|
||||
Humidity int `json:"humidity"`
|
||||
SeaLevel int `json:"sea_level,omitempty"`
|
||||
GrndLevel int `json:"grnd_level,omitempty"`
|
||||
} `json:"main"`
|
||||
Visibility int `json:"visibility"`
|
||||
Wind *struct {
|
||||
Speed float64 `json:"speed"`
|
||||
Deg int `json:"deg"`
|
||||
Gust float64 `json:"gust,omitempty"`
|
||||
} `json:"wind"`
|
||||
Rain *struct {
|
||||
OneH float64 `json:"1h"`
|
||||
} `json:"rain,omitempty"`
|
||||
Snow *struct {
|
||||
OneH float64 `json:"1h"`
|
||||
} `json:"snow,omitempty"`
|
||||
Clouds *struct {
|
||||
All int `json:"all"`
|
||||
} `json:"clouds"`
|
||||
Dt int64 `json:"dt"`
|
||||
Sys *struct {
|
||||
Type int `json:"type,omitempty"`
|
||||
ID int `json:"id,omitempty"`
|
||||
Country string `json:"country"`
|
||||
Sunrise int64 `json:"sunrise"`
|
||||
Sunset int64 `json:"sunset"`
|
||||
} `json:"sys"`
|
||||
Timezone int `json:"timezone"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Cod int `json:"cod"`
|
||||
}
|
||||
|
||||
type OpenWeatherMapFetcher struct {
|
||||
@ -80,11 +91,10 @@ func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, l
|
||||
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
|
||||
queryParams.Set("units", "metric")
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", openWeatherMapOneCallAPIURL, queryParams.Encode())
|
||||
f.logger.Debug("Fetching weather from OpenWeatherMap OneCall API", "url", fullURL)
|
||||
fullURL := fmt.Sprintf("%s?%s", openWeatherMapCurrentAPIURL, queryParams.Encode())
|
||||
f.logger.Debug("Fetching weather from OpenWeatherMap Current API", "url", fullURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
@ -100,7 +110,6 @@ func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, l
|
||||
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,
|
||||
@ -109,46 +118,60 @@ func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, l
|
||||
return nil, fmt.Errorf("weather API request failed with status: %s", resp.Status)
|
||||
}
|
||||
|
||||
var owmResp openWeatherMapOneCallResponse
|
||||
var owmResp openWeatherMapCurrentResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&owmResp); err != nil {
|
||||
f.logger.Error("Failed to decode OpenWeatherMap OneCall response", "error", err)
|
||||
f.logger.Error("Failed to decode OpenWeatherMap Current 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
|
||||
// --- Data Mapping from openWeatherMapCurrentResponse to domain.WeatherData ---
|
||||
|
||||
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")
|
||||
if owmResp.Main == nil {
|
||||
f.logger.Error("OpenWeatherMap Current response missing 'main' data block", "lat", lat, "lon", lon)
|
||||
return nil, fmt.Errorf("main weather data block not found in API response")
|
||||
}
|
||||
|
||||
// Create domain object using pointers for optional fields
|
||||
weatherData := &domain.WeatherData{} // Initialize empty struct first
|
||||
weatherData := &domain.WeatherData{}
|
||||
|
||||
// Assign values using pointers, checking for nil where appropriate
|
||||
weatherData.TempCelsius = ¤t.Temp
|
||||
humidityFloat := float64(current.Humidity)
|
||||
weatherData.TempCelsius = &owmResp.Main.Temp
|
||||
humidityFloat := float64(owmResp.Main.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
|
||||
|
||||
if len(owmResp.Weather) > 0 {
|
||||
weatherData.Description = &owmResp.Weather[0].Description
|
||||
weatherData.Icon = &owmResp.Weather[0].Icon
|
||||
} else {
|
||||
f.logger.Warn("OpenWeatherMap Current response missing 'weather' description details", "lat", lat, "lon", lon)
|
||||
}
|
||||
observedTime := time.Unix(current.Dt, 0).UTC()
|
||||
|
||||
if owmResp.Wind != nil {
|
||||
weatherData.WindSpeed = &owmResp.Wind.Speed
|
||||
} else {
|
||||
f.logger.Warn("OpenWeatherMap Current response missing 'wind' data block", "lat", lat, "lon", lon)
|
||||
}
|
||||
|
||||
if owmResp.Rain != nil {
|
||||
weatherData.RainVolume1h = &owmResp.Rain.OneH
|
||||
}
|
||||
|
||||
observedTime := time.Unix(owmResp.Dt, 0).UTC()
|
||||
weatherData.ObservedAt = &observedTime
|
||||
now := time.Now().UTC()
|
||||
weatherData.WeatherLastUpdated = &now
|
||||
|
||||
f.logger.Debug("Successfully fetched weather data",
|
||||
logTemp := "nil"
|
||||
if weatherData.TempCelsius != nil {
|
||||
logTemp = fmt.Sprintf("%.2f", *weatherData.TempCelsius)
|
||||
}
|
||||
logDesc := "nil"
|
||||
if weatherData.Description != nil {
|
||||
logDesc = *weatherData.Description
|
||||
}
|
||||
f.logger.Debug("Successfully fetched and mapped weather data",
|
||||
"lat", lat,
|
||||
"lon", lon,
|
||||
"temp", *weatherData.TempCelsius,
|
||||
"description", *weatherData.Description)
|
||||
"temp", logTemp,
|
||||
"description", logDesc)
|
||||
|
||||
return weatherData, nil
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package workers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
@ -27,13 +28,23 @@ func NewWeatherUpdater(
|
||||
eventPublisher domain.EventPublisher,
|
||||
logger *slog.Logger,
|
||||
fetchInterval time.Duration,
|
||||
) *WeatherUpdater {
|
||||
) (*WeatherUpdater, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if fetchInterval <= 0 {
|
||||
fetchInterval = 15 * time.Minute
|
||||
fetchInterval = 60 * time.Minute
|
||||
}
|
||||
if farmRepo == nil {
|
||||
return nil, fmt.Errorf("farmRepo cannot be nil")
|
||||
}
|
||||
if weatherFetcher == nil {
|
||||
return nil, fmt.Errorf("weatherFetcher cannot be nil")
|
||||
}
|
||||
if eventPublisher == nil {
|
||||
return nil, fmt.Errorf("eventPublisher cannot be nil")
|
||||
}
|
||||
|
||||
return &WeatherUpdater{
|
||||
farmRepo: farmRepo,
|
||||
weatherFetcher: weatherFetcher,
|
||||
@ -41,7 +52,7 @@ func NewWeatherUpdater(
|
||||
logger: logger,
|
||||
fetchInterval: fetchInterval,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *WeatherUpdater) Start(ctx context.Context) {
|
||||
@ -75,20 +86,22 @@ func (w *WeatherUpdater) Start(ctx context.Context) {
|
||||
|
||||
func (w *WeatherUpdater) Stop() {
|
||||
w.logger.Info("Attempting to stop Weather Updater worker...")
|
||||
close(w.stopChan)
|
||||
w.wg.Wait()
|
||||
select {
|
||||
case <-w.stopChan:
|
||||
default:
|
||||
close(w.stopChan)
|
||||
}
|
||||
w.wg.Wait() // Wait for the goroutine to finish
|
||||
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()
|
||||
repoCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Use separate context for DB query
|
||||
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 !!
|
||||
farms, err := w.farmRepo.GetAll(repoCtx) // <-- Changed method call
|
||||
if err != nil {
|
||||
w.logger.Error("Failed to get farms for weather update", "error", err)
|
||||
w.logger.Error("Failed to get all farms for weather update", "error", err)
|
||||
return
|
||||
}
|
||||
if len(farms) == 0 {
|
||||
@ -96,12 +109,15 @@ func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
w.logger.Info("Found farms for weather update", "count", len(farms))
|
||||
w.logger.Info("Processing farms for weather update", "count", len(farms))
|
||||
|
||||
var fetchWg sync.WaitGroup
|
||||
fetchCtx, cancelFetches := context.WithCancel(ctx)
|
||||
defer cancelFetches()
|
||||
|
||||
concurrencyLimit := 5
|
||||
sem := make(chan struct{}, concurrencyLimit)
|
||||
|
||||
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)
|
||||
@ -109,10 +125,14 @@ func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
|
||||
}
|
||||
|
||||
fetchWg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(f domain.Farm) {
|
||||
defer fetchWg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
select {
|
||||
case <-fetchCtx.Done():
|
||||
w.logger.Info("Weather fetch cancelled for farm", "farm_id", f.UUID, "reason", fetchCtx.Err())
|
||||
return
|
||||
default:
|
||||
w.fetchAndPublishWeather(fetchCtx, f)
|
||||
@ -121,7 +141,7 @@ func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
|
||||
}
|
||||
|
||||
fetchWg.Wait()
|
||||
w.logger.Info("Finished weather fetch cycle for farms", "count", len(farms))
|
||||
w.logger.Debug("Finished weather fetch cycle for farms", "count", len(farms)) // Use Debug for cycle completion
|
||||
}
|
||||
|
||||
func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain.Farm) {
|
||||
@ -136,17 +156,17 @@ func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain
|
||||
}
|
||||
|
||||
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,
|
||||
"farm_id": farm.UUID,
|
||||
"lat": farm.Lat,
|
||||
"lon": farm.Lon,
|
||||
"tempCelsius": weatherData.TempCelsius,
|
||||
"humidity": weatherData.Humidity,
|
||||
"description": weatherData.Description,
|
||||
"icon": weatherData.Icon,
|
||||
"windSpeed": weatherData.WindSpeed,
|
||||
"rainVolume1h": weatherData.RainVolume1h,
|
||||
"observedAt": weatherData.ObservedAt,
|
||||
"weatherLastUpdated": weatherData.WeatherLastUpdated,
|
||||
}
|
||||
|
||||
event := domain.Event{
|
||||
|
||||
@ -17,8 +17,8 @@ export interface RegisterResponse {
|
||||
export async function registerUser(email: string, password: string): Promise<RegisterResponse> {
|
||||
try {
|
||||
const response = await axiosInstance.post("/auth/register", {
|
||||
Email: email,
|
||||
Password: password,
|
||||
email: email,
|
||||
password: password,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
@ -35,8 +35,8 @@ export async function registerUser(email: string, password: string): Promise<Reg
|
||||
export async function loginUser(email: string, password: string): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await axiosInstance.post("/auth/login", {
|
||||
Email: email,
|
||||
Password: password,
|
||||
email: email,
|
||||
password: password,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
|
||||
73
frontend/api/chat.ts
Normal file
73
frontend/api/chat.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import axiosInstance from "./config";
|
||||
|
||||
interface HistoryItem {
|
||||
role: "user" | "model" | "assistant";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ChatResponse {
|
||||
response: string;
|
||||
// Include history if backend returns it, otherwise frontend manages it
|
||||
}
|
||||
|
||||
// Map frontend 'assistant' role to backend 'model' role if needed
|
||||
const mapRoleForApi = (role: "user" | "assistant"): "user" | "model" => {
|
||||
return role === "assistant" ? "model" : role;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a chat message to the backend.
|
||||
* Determines the endpoint based on the presence of farmId and cropId.
|
||||
*
|
||||
* @param message The user's message.
|
||||
* @param history The conversation history.
|
||||
* @param farmId Optional farm ID for context.
|
||||
* @param cropId Optional crop ID for context.
|
||||
* @returns The assistant's response.
|
||||
*/
|
||||
export async function sendChatMessage(
|
||||
message: string,
|
||||
history: HistoryItem[],
|
||||
farmId?: string | null,
|
||||
cropId?: string | null
|
||||
): Promise<ChatResponse> {
|
||||
const endpoint = farmId || cropId ? "/chat/specific" : "/chat"; // Use /chatbot if no IDs
|
||||
const apiHistory = history.map((item) => ({
|
||||
role: mapRoleForApi(item.role as "user" | "assistant"),
|
||||
text: item.text,
|
||||
}));
|
||||
|
||||
const payload: {
|
||||
message: string;
|
||||
farmId?: string;
|
||||
cropId?: string;
|
||||
history: { role: "user" | "model"; text: string }[];
|
||||
} = {
|
||||
message,
|
||||
history: apiHistory,
|
||||
};
|
||||
|
||||
// Only include IDs if calling the contextual endpoint
|
||||
if (farmId || cropId) {
|
||||
if (farmId) payload.farmId = farmId;
|
||||
if (cropId) payload.cropId = cropId;
|
||||
}
|
||||
|
||||
console.log(`Sending chat message to ${endpoint} with payload:`, payload);
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.post<ChatResponse>(endpoint, payload);
|
||||
console.log("Received chat response:", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`Error sending chat message to ${endpoint}:`, error);
|
||||
// Provide a user-friendly error message
|
||||
const errorMessage =
|
||||
(error as any).response?.data?.message ||
|
||||
"Sorry, I couldn't connect to the assistant right now. Please try again later.";
|
||||
// Throw an error with a useful message or return a specific error structure
|
||||
// throw new Error(errorMessage);
|
||||
// Or return an error response structure:
|
||||
return { response: errorMessage };
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// frontend/api/crop.ts
|
||||
import axiosInstance from "./config";
|
||||
// Use refactored types
|
||||
import type { Cropland, CropAnalytics } from "@/types";
|
||||
|
||||
export interface CropResponse {
|
||||
@ -7,30 +7,72 @@ export interface CropResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all Croplands for a specific FarmID. Returns CropResponse.
|
||||
* Fetch all Croplands for a specific FarmID.
|
||||
*/
|
||||
export async function getCropsByFarmId(farmId: string): Promise<CropResponse> {
|
||||
// Assuming backend returns { "croplands": [...] }
|
||||
return axiosInstance.get<CropResponse>(`/crop/farm/${farmId}`).then((res) => res.data);
|
||||
return axiosInstance.get<{ croplands: Cropland[] }>(`/crop/farm/${farmId}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific Cropland by its ID. Returns Cropland.
|
||||
* Fetch a specific Cropland by its ID.
|
||||
*/
|
||||
export async function getCropById(cropId: string): Promise<Cropland> {
|
||||
// Assuming backend returns { "cropland": ... }
|
||||
return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
|
||||
// If backend returns object directly: return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
|
||||
const response = await axiosInstance.get<{ cropland: Cropland }>(`/crop/${cropId}`);
|
||||
return response.data.cropland;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new crop (Cropland). Sends camelCase data matching backend tags. Returns Cropland.
|
||||
* Create a new crop (Cropland).
|
||||
*/
|
||||
export async function createCrop(data: Partial<Omit<Cropland, "uuid" | "createdAt" | "updatedAt">>): Promise<Cropland> {
|
||||
export async function createCrop(data: {
|
||||
name: string;
|
||||
status: string;
|
||||
priority?: number;
|
||||
landSize?: number;
|
||||
growthStage: string;
|
||||
plantId: string;
|
||||
farmId: string;
|
||||
geoFeature?: unknown | null;
|
||||
}): Promise<Cropland> {
|
||||
if (!data.farmId) {
|
||||
throw new Error("farmId is required to create a crop.");
|
||||
}
|
||||
// Payload uses camelCase keys matching backend JSON tags
|
||||
|
||||
const payload = {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
priority: data.priority ?? 0,
|
||||
landSize: data.landSize ?? 0,
|
||||
growthStage: data.growthStage,
|
||||
plantId: data.plantId,
|
||||
farmId: data.farmId,
|
||||
geoFeature: data.geoFeature,
|
||||
};
|
||||
|
||||
const response = await axiosInstance.post<{ cropland: Cropland }>(`/crop`, payload);
|
||||
return response.data.cropland;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing cropland by its ID.
|
||||
* Note: farmId cannot be changed via this endpoint
|
||||
*/
|
||||
export async function updateCrop(
|
||||
cropId: string,
|
||||
data: {
|
||||
name: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
landSize: number;
|
||||
growthStage: string;
|
||||
plantId: string;
|
||||
geoFeature: unknown | null;
|
||||
}
|
||||
): Promise<Cropland> {
|
||||
if (!cropId) {
|
||||
throw new Error("cropId is required to update a crop.");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
@ -38,17 +80,36 @@ export async function createCrop(data: Partial<Omit<Cropland, "uuid" | "createdA
|
||||
landSize: data.landSize,
|
||||
growthStage: data.growthStage,
|
||||
plantId: data.plantId,
|
||||
farmId: data.farmId,
|
||||
geoFeature: data.geoFeature, // Send the GeoFeature object
|
||||
geoFeature: data.geoFeature,
|
||||
};
|
||||
return axiosInstance.post<{ cropland: Cropland }>(`/crop`, payload).then((res) => res.data.cropland); // Assuming backend wraps in { "cropland": ... }
|
||||
// If backend returns object directly: return axiosInstance.post<Cropland>(`/crop`, payload).then((res) => res.data);
|
||||
|
||||
const response = await axiosInstance.put<{ cropland: Cropland }>(`/crop/${cropId}`, payload);
|
||||
return response.data.cropland;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch analytics data for a specific crop by its ID. Returns CropAnalytics.
|
||||
* Delete a specific cropland by its ID.
|
||||
*/
|
||||
export async function fetchCropAnalytics(cropId: string): Promise<CropAnalytics> {
|
||||
// Assuming backend returns { body: { ... } } structure from Huma
|
||||
return axiosInstance.get<CropAnalytics>(`/analytics/crop/${cropId}`).then((res) => res.data);
|
||||
export async function deleteCrop(cropId: string): Promise<{ message: string } | void> {
|
||||
const response = await axiosInstance.delete(`/crop/${cropId}`);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return response.data as { message: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch analytics data for a specific crop by its ID.
|
||||
*/
|
||||
export async function fetchCropAnalytics(cropId: string): Promise<CropAnalytics | null> {
|
||||
try {
|
||||
const response = await axiosInstance.get<CropAnalytics>(`/analytics/crop/${cropId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching crop analytics:", error);
|
||||
if (error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
29
frontend/api/profile.ts
Normal file
29
frontend/api/profile.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import axiosInstance from "./config";
|
||||
import type { User } from "@/types";
|
||||
|
||||
export interface UpdateUserProfileInput {
|
||||
username?: string;
|
||||
// email?: string;
|
||||
// avatar?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user's profile information.
|
||||
* Sends a PUT request to the /user/me endpoint.
|
||||
* @param data - An object containing the fields to update.
|
||||
* @returns The updated user data from the backend.
|
||||
*/
|
||||
export async function updateUserProfile(data: UpdateUserProfileInput): Promise<User> {
|
||||
try {
|
||||
// Backend expects { user: ... } in the response body
|
||||
const response = await axiosInstance.put<{ user: User }>("/user/me", data);
|
||||
return response.data.user;
|
||||
} catch (error) {
|
||||
console.error("Error updating user profile:", error);
|
||||
throw new Error(
|
||||
(error as any).response?.data?.detail ||
|
||||
(error as any).response?.data?.message ||
|
||||
"Failed to update profile. Please try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,204 +2,69 @@
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Send,
|
||||
Clock,
|
||||
X,
|
||||
Leaf,
|
||||
MessageSquare,
|
||||
History,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Send, MessageSquare, Sparkles, Loader2, User, Bot } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { Farm, Crop } from "@/types";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; // Assuming Avatar is in ui folder
|
||||
import { sendChatMessage } from "@/api/chat"; // Import the API function
|
||||
|
||||
// Mock data for farms and crops
|
||||
const mockFarms: Farm[] = [
|
||||
{
|
||||
id: "farm1",
|
||||
name: "Green Valley Farm",
|
||||
location: "California",
|
||||
type: "Organic",
|
||||
createdAt: new Date("2023-01-15"),
|
||||
area: "120 acres",
|
||||
crops: 8,
|
||||
weather: {
|
||||
temperature: 24,
|
||||
humidity: 65,
|
||||
rainfall: "2mm",
|
||||
sunlight: 80,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "farm2",
|
||||
name: "Sunrise Fields",
|
||||
location: "Iowa",
|
||||
type: "Conventional",
|
||||
createdAt: new Date("2022-11-05"),
|
||||
area: "350 acres",
|
||||
crops: 5,
|
||||
weather: {
|
||||
temperature: 22,
|
||||
humidity: 58,
|
||||
rainfall: "0mm",
|
||||
sunlight: 90,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockCrops: Crop[] = [
|
||||
{
|
||||
id: "crop1",
|
||||
farmId: "farm1",
|
||||
name: "Organic Tomatoes",
|
||||
plantedDate: new Date("2023-03-10"),
|
||||
status: "Growing",
|
||||
variety: "Roma",
|
||||
area: "15 acres",
|
||||
healthScore: 92,
|
||||
progress: 65,
|
||||
},
|
||||
{
|
||||
id: "crop2",
|
||||
farmId: "farm1",
|
||||
name: "Sweet Corn",
|
||||
plantedDate: new Date("2023-04-05"),
|
||||
status: "Growing",
|
||||
variety: "Golden Bantam",
|
||||
area: "25 acres",
|
||||
healthScore: 88,
|
||||
progress: 45,
|
||||
},
|
||||
{
|
||||
id: "crop3",
|
||||
farmId: "farm2",
|
||||
name: "Soybeans",
|
||||
plantedDate: new Date("2023-05-15"),
|
||||
status: "Growing",
|
||||
variety: "Pioneer",
|
||||
area: "120 acres",
|
||||
healthScore: 95,
|
||||
progress: 30,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock chat history
|
||||
// Interface for chat messages
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant"; // Changed sender to role
|
||||
content: string;
|
||||
sender: "user" | "bot";
|
||||
timestamp: Date;
|
||||
relatedTo?: {
|
||||
type: "farm" | "crop";
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
const mockChatHistory: ChatMessage[] = [
|
||||
{
|
||||
id: "msg1",
|
||||
content: "When should I harvest my tomatoes?",
|
||||
sender: "user",
|
||||
timestamp: new Date("2023-07-15T10:30:00"),
|
||||
relatedTo: {
|
||||
type: "crop",
|
||||
id: "crop1",
|
||||
name: "Organic Tomatoes",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "msg2",
|
||||
content:
|
||||
"Based on the current growth stage of your Roma tomatoes, they should be ready for harvest in approximately 2-3 weeks. The ideal time to harvest is when they've developed their full red color but are still firm to the touch. Keep monitoring the soil moisture levels as consistent watering during the final ripening stage is crucial for flavor development.",
|
||||
sender: "bot",
|
||||
timestamp: new Date("2023-07-15T10:30:30"),
|
||||
},
|
||||
{
|
||||
id: "msg3",
|
||||
content: "What's the best fertilizer for corn?",
|
||||
sender: "user",
|
||||
timestamp: new Date("2023-07-16T14:22:00"),
|
||||
relatedTo: {
|
||||
type: "crop",
|
||||
id: "crop2",
|
||||
name: "Sweet Corn",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "msg4",
|
||||
content:
|
||||
"For your Sweet Corn at Green Valley Farm, I recommend a nitrogen-rich fertilizer with an NPK ratio of approximately 16-4-8. Corn is a heavy nitrogen feeder, especially during its growth phase. Apply the fertilizer when the plants are knee-high and again when they begin to tassel. Based on your soil analysis, consider supplementing with sulfur to address the slight deficiency detected in your last soil test.",
|
||||
sender: "bot",
|
||||
timestamp: new Date("2023-07-16T14:22:45"),
|
||||
},
|
||||
];
|
||||
|
||||
// Recommended prompts
|
||||
// Recommended prompts (keep or adjust as needed)
|
||||
const recommendedPrompts = [
|
||||
{
|
||||
id: "prompt1",
|
||||
text: "When should I water my crops?",
|
||||
category: "Irrigation",
|
||||
text: "What are common signs of nutrient deficiency in plants?",
|
||||
category: "Plant Health",
|
||||
},
|
||||
{
|
||||
id: "prompt2",
|
||||
text: "How can I improve soil health?",
|
||||
text: "How can I improve soil drainage?",
|
||||
category: "Soil Management",
|
||||
},
|
||||
{
|
||||
id: "prompt3",
|
||||
text: "What pests might affect my crops this season?",
|
||||
text: "Explain integrated pest management (IPM).",
|
||||
category: "Pest Control",
|
||||
},
|
||||
{
|
||||
id: "prompt4",
|
||||
text: "Recommend a crop rotation plan",
|
||||
text: "What are the benefits of crop rotation?",
|
||||
category: "Planning",
|
||||
},
|
||||
{
|
||||
id: "prompt5",
|
||||
text: "How to maximize yield for my current crops?",
|
||||
category: "Optimization",
|
||||
text: "Tell me about sustainable farming practices.",
|
||||
category: "Sustainability",
|
||||
},
|
||||
{
|
||||
id: "prompt6",
|
||||
text: "What's the best time to harvest?",
|
||||
category: "Harvesting",
|
||||
text: "How does weather affect crop yield?",
|
||||
category: "Weather",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ChatbotPage() {
|
||||
export default function GeneralChatbotPage() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const [selectedFarm, setSelectedFarm] = useState<string | null>(null);
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false); // Loading state for API call
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize with a welcome message
|
||||
useEffect(() => {
|
||||
setMessages([
|
||||
{
|
||||
id: "welcome",
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content:
|
||||
"👋 Hello! I'm ForFarm Assistant, your farming AI companion. How can I help you today? You can ask me about crop management, pest control, weather impacts, or select a specific farm or crop to get tailored advice.",
|
||||
sender: "bot",
|
||||
"👋 Hello! I'm ForFarm Assistant, your general farming AI companion. Ask me anything about agriculture, crops, soil, weather, or best practices!",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
@ -210,483 +75,181 @@ export default function ChatbotPage() {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Filter crops based on selected farm
|
||||
const filteredCrops = selectedFarm ? mockCrops.filter((crop) => crop.farmId === selectedFarm) : mockCrops;
|
||||
|
||||
// Handle sending a message
|
||||
const handleSendMessage = (content: string = inputValue) => {
|
||||
if (!content.trim()) return;
|
||||
const handleSendMessage = async (content: string = inputValue) => {
|
||||
if (!content.trim() || isLoading) return;
|
||||
|
||||
// Create user message
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content,
|
||||
sender: "user",
|
||||
timestamp: new Date(),
|
||||
...(selectedFarm || selectedCrop
|
||||
? {
|
||||
relatedTo: {
|
||||
type: selectedCrop ? "crop" : "farm",
|
||||
id: selectedCrop || selectedFarm || "",
|
||||
name: selectedCrop
|
||||
? mockCrops.find((c) => c.id === selectedCrop)?.name || ""
|
||||
: mockFarms.find((f) => f.id === selectedFarm)?.name || "",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputValue("");
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate bot response after a delay
|
||||
setTimeout(() => {
|
||||
const botResponse: ChatMessage = {
|
||||
id: `bot-${Date.now()}`,
|
||||
content: generateBotResponse(content, selectedFarm, selectedCrop),
|
||||
sender: "bot",
|
||||
// Prepare history for the API call
|
||||
const apiHistory = messages
|
||||
.filter((msg) => msg.role === "user" || msg.role === "assistant")
|
||||
.map((msg) => ({ role: msg.role, text: msg.content }));
|
||||
|
||||
try {
|
||||
// Call the API function *without* farmId and cropId
|
||||
const response = await sendChatMessage(userMessage.content, apiHistory);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: response.response,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, botResponse]);
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error("Error sending general chat message:", error);
|
||||
const errorMessage: ChatMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: `Sorry, I encountered an issue. ${(error as Error).message || "Please try again later."}`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Generate a bot response based on the user's message and selected farm/crop
|
||||
const generateBotResponse = (message: string, farmId: string | null, cropId: string | null): string => {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
// Get farm and crop details if selected
|
||||
const farm = farmId ? mockFarms.find((f) => f.id === farmId) : null;
|
||||
const crop = cropId ? mockCrops.find((c) => c.id === cropId) : null;
|
||||
|
||||
// Personalize response based on selected farm/crop
|
||||
let contextPrefix = "";
|
||||
if (crop) {
|
||||
contextPrefix = `For your ${crop.name} (${crop.variety}) at ${farm?.name || "your farm"}, `;
|
||||
} else if (farm) {
|
||||
contextPrefix = `For ${farm.name}, `;
|
||||
}
|
||||
|
||||
// Generate response based on message content
|
||||
if (lowerMessage.includes("water") || lowerMessage.includes("irrigation")) {
|
||||
return `${contextPrefix}I recommend watering deeply but infrequently to encourage strong root growth. Based on the current weather conditions${
|
||||
farm ? ` in ${farm.location}` : ""
|
||||
} (${farm?.weather?.rainfall || "minimal"} rainfall recently), you should water ${
|
||||
crop ? `your ${crop.name}` : "your crops"
|
||||
} approximately 2-3 times per week, ensuring the soil remains moist but not waterlogged.`;
|
||||
} else if (lowerMessage.includes("fertiliz") || lowerMessage.includes("nutrient")) {
|
||||
return `${contextPrefix}a balanced NPK fertilizer with a ratio of 10-10-10 would be suitable for general application. ${
|
||||
crop
|
||||
? `For ${crop.name} specifically, consider increasing ${
|
||||
crop.name.toLowerCase().includes("tomato")
|
||||
? "potassium"
|
||||
: crop.name.toLowerCase().includes("corn")
|
||||
? "nitrogen"
|
||||
: "phosphorus"
|
||||
} for optimal growth during the current ${
|
||||
crop.progress && crop.progress < 30 ? "early" : crop.progress && crop.progress < 70 ? "middle" : "late"
|
||||
} growth stage.`
|
||||
: ""
|
||||
}`;
|
||||
} else if (lowerMessage.includes("pest") || lowerMessage.includes("insect") || lowerMessage.includes("disease")) {
|
||||
return `${contextPrefix}monitor for ${
|
||||
crop
|
||||
? crop.name.toLowerCase().includes("tomato")
|
||||
? "tomato hornworms, aphids, and early blight"
|
||||
: crop.name.toLowerCase().includes("corn")
|
||||
? "corn borers, rootworms, and rust"
|
||||
: "common agricultural pests"
|
||||
: "common agricultural pests like aphids, beetles, and fungal diseases"
|
||||
}. I recommend implementing integrated pest management (IPM) practices, including regular scouting, beneficial insects, and targeted treatments only when necessary.`;
|
||||
} else if (lowerMessage.includes("harvest") || lowerMessage.includes("yield")) {
|
||||
return `${contextPrefix}${
|
||||
crop
|
||||
? `your ${crop.name} should be ready to harvest in approximately ${Math.max(
|
||||
1,
|
||||
Math.round((100 - (crop.progress || 50)) / 10)
|
||||
)} weeks based on the current growth stage. Look for ${
|
||||
crop.name.toLowerCase().includes("tomato")
|
||||
? "firm, fully colored fruits"
|
||||
: crop.name.toLowerCase().includes("corn")
|
||||
? "full ears with dried silk and plump kernels"
|
||||
: "signs of maturity specific to this crop type"
|
||||
}`
|
||||
: "harvest timing depends on the specific crops you're growing, but generally you should look for visual cues of ripeness and maturity"
|
||||
}.`;
|
||||
} else if (lowerMessage.includes("soil") || lowerMessage.includes("compost")) {
|
||||
return `${contextPrefix}improving soil health is crucial for sustainable farming. I recommend regular soil testing, adding organic matter through compost or cover crops, practicing crop rotation, and minimizing soil disturbance. ${
|
||||
farm
|
||||
? `Based on the soil type common in ${farm.location}, you might also consider adding ${
|
||||
farm.location.includes("California") ? "gypsum to improve drainage" : "lime to adjust pH levels"
|
||||
}.`
|
||||
: ""
|
||||
}`;
|
||||
} else if (lowerMessage.includes("weather") || lowerMessage.includes("forecast") || lowerMessage.includes("rain")) {
|
||||
return `${contextPrefix}${
|
||||
farm
|
||||
? `the current conditions show temperature at ${farm.weather?.temperature}°C with ${farm.weather?.humidity}% humidity. There's been ${farm.weather?.rainfall} of rainfall recently, and sunlight levels are at ${farm.weather?.sunlight}% of optimal.`
|
||||
: "I recommend checking your local agricultural weather service for the most accurate forecast for your specific location."
|
||||
} ${
|
||||
crop
|
||||
? `For your ${crop.name}, ${
|
||||
farm?.weather?.rainfall === "0mm"
|
||||
? "the dry conditions mean you should increase irrigation"
|
||||
: "the recent rainfall means you can reduce irrigation temporarily"
|
||||
}.`
|
||||
: ""
|
||||
}`;
|
||||
} else {
|
||||
return `${contextPrefix}I understand you're asking about "${message}". To provide the most helpful advice, could you provide more specific details about your farming goals or challenges? I'm here to help with crop management, pest control, irrigation strategies, and more.`;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle selecting a farm
|
||||
const handleFarmSelect = (farmId: string) => {
|
||||
setSelectedFarm(farmId);
|
||||
setSelectedCrop(null); // Reset crop selection when farm changes
|
||||
};
|
||||
|
||||
// Handle selecting a crop
|
||||
const handleCropSelect = (cropId: string) => {
|
||||
setSelectedCrop(cropId);
|
||||
};
|
||||
|
||||
// Handle clicking a recommended prompt
|
||||
const handlePromptClick = (promptText: string) => {
|
||||
setInputValue(promptText);
|
||||
handleSendMessage(promptText);
|
||||
};
|
||||
|
||||
// Handle loading a chat history item
|
||||
const handleLoadChatHistory = (messageId: string) => {
|
||||
// Find the message in history
|
||||
const historyItem = mockChatHistory.find((msg) => msg.id === messageId);
|
||||
if (!historyItem) return;
|
||||
|
||||
// Set related farm/crop if available
|
||||
if (historyItem.relatedTo) {
|
||||
if (historyItem.relatedTo.type === "farm") {
|
||||
setSelectedFarm(historyItem.relatedTo.id);
|
||||
setSelectedCrop(null);
|
||||
} else if (historyItem.relatedTo.type === "crop") {
|
||||
const crop = mockCrops.find((c) => c.id === historyItem.relatedTo?.id);
|
||||
if (crop) {
|
||||
setSelectedFarm(crop.farmId);
|
||||
setSelectedCrop(historyItem.relatedTo.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load the conversation
|
||||
const conversation = mockChatHistory.filter(
|
||||
(msg) =>
|
||||
msg.id === messageId ||
|
||||
(msg.timestamp >= historyItem.timestamp && msg.timestamp <= new Date(historyItem.timestamp.getTime() + 60000))
|
||||
);
|
||||
|
||||
setMessages(conversation);
|
||||
setIsHistoryOpen(false);
|
||||
// Directly send message after setting input
|
||||
// The handleSendMessage function will pick up the new inputValue
|
||||
// No need to call handleSendMessage here if the button triggers submit or calls it
|
||||
// Let's assume the button just sets the input and the user clicks send
|
||||
// OR: uncomment the line below if the button should send immediately
|
||||
// handleSendMessage(promptText);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-white dark:bg-gray-950 shadow-sm py-4 px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/farms")} aria-label="Back to farms">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<h1 className="text-xl font-semibold">ForFarm Assistant</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||
aria-label={isHistoryOpen ? "Close history" : "Open history"}>
|
||||
{isHistoryOpen ? <PanelRightClose className="h-5 w-5" /> : <PanelRightOpen className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950">
|
||||
{/* Header (Optional - can be simplified as it's part of the main layout) */}
|
||||
<header className="border-b bg-white dark:bg-gray-950 shadow-sm py-4 px-6 flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<h1 className="text-xl font-semibold">General Farming Assistant</h1>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
{/* Farm/Crop selector */}
|
||||
<div className="bg-white dark:bg-gray-900 p-4 border-b">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-1 block text-gray-700 dark:text-gray-300">
|
||||
Select Farm (Optional)
|
||||
</label>
|
||||
<Select value={selectedFarm || ""} onValueChange={handleFarmSelect}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="All Farms" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Farms</SelectItem>
|
||||
{mockFarms.map((farm) => (
|
||||
<SelectItem key={farm.id} value={farm.id}>
|
||||
{farm.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-1 block text-gray-700 dark:text-gray-300">
|
||||
Select Crop (Optional)
|
||||
</label>
|
||||
<Select value={selectedCrop || ""} onValueChange={handleCropSelect} disabled={!selectedFarm}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={selectedFarm ? "All Crops" : "Select a farm first"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedFarm && <SelectItem value="all">All Crops</SelectItem>}
|
||||
{filteredCrops.map((crop) => (
|
||||
<SelectItem key={crop.id} value={crop.id}>
|
||||
{crop.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-4 ${
|
||||
message.sender === "user"
|
||||
? "bg-green-600 text-white dark:bg-green-700"
|
||||
: "bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm"
|
||||
}`}>
|
||||
{message.relatedTo && (
|
||||
<div className="mb-1">
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{message.relatedTo.type === "farm" ? "🏡 " : "🌱 "}
|
||||
{message.relatedTo.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">{message.content}</div>
|
||||
<div className="mt-1 text-xs opacity-70 text-right">
|
||||
{message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-150"></div>
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-300"></div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-1">
|
||||
ForFarm Assistant is typing...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Chat Area */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6 max-w-4xl mx-auto pb-4">
|
||||
{messages.map((message, i) => (
|
||||
<div
|
||||
key={message.id || i}
|
||||
className={`flex items-start gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
|
||||
{message.role === "assistant" && (
|
||||
<Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0">
|
||||
<AvatarFallback>
|
||||
<Bot className="h-5 w-5 text-green-600" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-3 shadow-sm ${
|
||||
message.role === "user"
|
||||
? "bg-green-600 text-white dark:bg-green-700"
|
||||
: "bg-white dark:bg-gray-800 border dark:border-gray-700"
|
||||
}`}>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
<p
|
||||
className={`mt-1 text-xs opacity-70 ${
|
||||
message.role === "user" ? "text-green-100" : "text-gray-500 dark:text-gray-400"
|
||||
} text-right`}>
|
||||
{message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
</div>
|
||||
{message.role === "user" && (
|
||||
<Avatar className="h-8 w-8 border bg-gray-200 dark:bg-gray-600 flex-shrink-0">
|
||||
<AvatarFallback>
|
||||
<User className="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Recommended prompts */}
|
||||
<div className="bg-white dark:bg-gray-900 border-t p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
|
||||
<Sparkles className="h-4 w-4 text-green-500" />
|
||||
Recommended Questions
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recommendedPrompts.map((prompt) => (
|
||||
<Button
|
||||
key={prompt.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs rounded-full bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40 text-green-800 dark:text-green-300"
|
||||
onClick={() => handlePromptClick(prompt.text)}>
|
||||
{prompt.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="bg-white dark:bg-gray-900 border-t p-4">
|
||||
<div className="flex gap-2 max-w-3xl mx-auto">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Ask about your farm or crops..."
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleSendMessage()}
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
className="bg-green-600 hover:bg-green-700 text-white">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat history sidebar */}
|
||||
{isHistoryOpen && (
|
||||
<div className="w-80 border-l bg-white dark:bg-gray-900 flex flex-col">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="font-medium flex items-center gap-1">
|
||||
<History className="h-4 w-4" />
|
||||
Chat History
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsHistoryOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
<Input placeholder="Search conversations..." className="pl-9" />
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0">
|
||||
<AvatarFallback>
|
||||
<Bot className="h-5 w-5 text-green-600" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="max-w-[80%] rounded-lg p-3 bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-150"></div>
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-300"></div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-1">Assistant is thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Tabs defaultValue="recent" className="flex-1 flex flex-col">
|
||||
<TabsList className="mx-3 mb-2">
|
||||
<TabsTrigger value="recent" className="flex-1">
|
||||
Recent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="farms" className="flex-1">
|
||||
By Farm
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="crops" className="flex-1">
|
||||
By Crop
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Recommended prompts */}
|
||||
<div className="bg-white dark:bg-gray-900 border-t p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
|
||||
<Sparkles className="h-4 w-4 text-green-500" />
|
||||
Try asking...
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recommendedPrompts.slice(0, 5).map(
|
||||
(
|
||||
prompt // Limit displayed prompts
|
||||
) => (
|
||||
<Button
|
||||
key={prompt.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs rounded-full bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40 text-green-800 dark:text-green-300"
|
||||
onClick={() => handlePromptClick(prompt.text)}>
|
||||
{prompt.text}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<TabsContent value="recent" className="m-0 p-0">
|
||||
<div className="space-y-1 p-2">
|
||||
{mockChatHistory
|
||||
.filter((msg) => msg.sender === "user")
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.map((message) => (
|
||||
<Card
|
||||
key={message.id}
|
||||
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
onClick={() => handleLoadChatHistory(message.id)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-8 w-8 bg-green-100 dark:bg-green-900">
|
||||
<div className="text-xs font-medium text-green-700 dark:text-green-300">
|
||||
{message.relatedTo?.name.substring(0, 2) || "Me"}
|
||||
</div>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{message.relatedTo ? message.relatedTo.name : "General Question"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{message.timestamp.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 truncate mt-1">
|
||||
{message.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="farms" className="m-0 p-0">
|
||||
<div className="space-y-3 p-3">
|
||||
{mockFarms.map((farm) => (
|
||||
<div key={farm.id}>
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{farm.name}</h3>
|
||||
<div className="space-y-1">
|
||||
{mockChatHistory
|
||||
.filter(
|
||||
(msg) =>
|
||||
msg.sender === "user" && msg.relatedTo?.type === "farm" && msg.relatedTo.id === farm.id
|
||||
)
|
||||
.map((message) => (
|
||||
<Card
|
||||
key={message.id}
|
||||
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
onClick={() => handleLoadChatHistory(message.id)}>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">{message.content}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{message.timestamp.toLocaleDateString()}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="crops" className="m-0 p-0">
|
||||
<div className="space-y-3 p-3">
|
||||
{mockCrops.map((crop) => (
|
||||
<div key={crop.id}>
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
|
||||
<Leaf className="h-3 w-3 text-green-600" />
|
||||
{crop.name}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({mockFarms.find((f) => f.id === crop.farmId)?.name})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{mockChatHistory
|
||||
.filter(
|
||||
(msg) =>
|
||||
msg.sender === "user" && msg.relatedTo?.type === "crop" && msg.relatedTo.id === crop.id
|
||||
)
|
||||
.map((message) => (
|
||||
<Card
|
||||
key={message.id}
|
||||
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
onClick={() => handleLoadChatHistory(message.id)}>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">{message.content}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{message.timestamp.toLocaleDateString()}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
{/* Input area */}
|
||||
<div className="bg-white dark:bg-gray-900 border-t p-4 sticky bottom-0">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}}
|
||||
className="flex gap-2 max-w-4xl mx-auto">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Ask the farming assistant..."
|
||||
className="flex-1 h-11"
|
||||
disabled={isLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
className="bg-green-600 hover:bg-green-700 text-white h-11 px-5">
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
<span className="sr-only">Send</span>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -36,16 +36,17 @@ import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-wi
|
||||
interface CropDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: Partial<Cropland>) => Promise<void>;
|
||||
onSubmit: (data: Partial<Omit<Cropland, "uuid" | "farmId">>) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
initialData?: Cropland | null;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropDialogProps) {
|
||||
export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting, initialData, isEditing }: CropDialogProps) {
|
||||
// --- State ---
|
||||
const [selectedPlantUUID, setSelectedPlantUUID] = useState<string | null>(null);
|
||||
// State to hold the structured GeoFeature data
|
||||
const [geoFeature, setGeoFeature] = useState<GeoFeatureData | null>(null);
|
||||
const [calculatedArea, setCalculatedArea] = useState<number | null>(null); // Keep for display
|
||||
const [calculatedArea, setCalculatedArea] = useState<number | null>(null);
|
||||
|
||||
// --- Load Google Maps Geometry Library ---
|
||||
const geometryLib = useMapsLibrary("geometry");
|
||||
@ -63,6 +64,7 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const plants = useMemo(() => plantData?.plants || [], [plantData]);
|
||||
|
||||
const selectedPlant = useMemo(() => {
|
||||
return plants.find((p) => p.uuid === selectedPlantUUID);
|
||||
}, [plants, selectedPlantUUID]);
|
||||
@ -71,10 +73,14 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedPlantUUID(null);
|
||||
setGeoFeature(null); // Reset geoFeature state
|
||||
setGeoFeature(null);
|
||||
setCalculatedArea(null);
|
||||
} else if (initialData) {
|
||||
setSelectedPlantUUID(initialData.plantId);
|
||||
setGeoFeature(initialData.geoFeature ?? null);
|
||||
setCalculatedArea(initialData.landSize ?? null);
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, initialData]);
|
||||
|
||||
// --- Map Interaction Handler ---
|
||||
const handleShapeDrawn = useCallback(
|
||||
@ -169,9 +175,13 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[950px] md:max-w-[1100px] lg:max-w-[1200px] xl:max-w-7xl p-0 max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="p-6 pb-0">
|
||||
<DialogTitle className="text-xl font-semibold">Create New Cropland</DialogTitle>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{isEditing ? "Edit Cropland" : "Create New Cropland"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a plant and draw the cropland boundary or mark its location on the map.
|
||||
{isEditing
|
||||
? "Update the cropland details and location."
|
||||
: "Select a plant and draw the cropland boundary or mark its location on the map."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Send } from "lucide-react";
|
||||
import { Send, Bot, Loader2, User } from "lucide-react";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { useParams } from "next/navigation";
|
||||
import { sendChatMessage } from "@/api/chat";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface ChatbotDialogProps {
|
||||
@ -20,52 +25,158 @@ interface ChatbotDialogProps {
|
||||
}
|
||||
|
||||
export function ChatbotDialog({ open, onOpenChange, cropName }: ChatbotDialogProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
role: "assistant",
|
||||
content: `Hello! I'm your farming assistant. How can I help you with your ${cropName} today?`,
|
||||
},
|
||||
]);
|
||||
const params = useParams<{ farmId: string; cropId: string }>();
|
||||
const { farmId, cropId } = params;
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false); // Loading state for API call
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim()) return;
|
||||
// Initialize with a welcome message when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMessages([
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: `Hello! How can I help you with ${cropName} (Crop ID: ${cropId}) today?`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
// Reset input when opening
|
||||
setInput("");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [open, cropName, cropId]); // Add dependencies
|
||||
|
||||
const newMessages: Message[] = [
|
||||
...messages,
|
||||
{ role: "user", content: input },
|
||||
{ role: "assistant", content: `Here's some information about ${cropName}: [AI response placeholder]` },
|
||||
];
|
||||
setMessages(newMessages);
|
||||
// Scroll to bottom of messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isLoading]); // Add isLoading to scroll when loading appears/disappears
|
||||
|
||||
const handleSend = async () => {
|
||||
const messageText = input.trim();
|
||||
if (!messageText || isLoading) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content: messageText,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Add user message immediately and clear input, set loading
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput("");
|
||||
setIsLoading(true); // Show loading indicator *now*
|
||||
|
||||
// Prepare history for API call (use messages *before* adding the placeholder)
|
||||
const apiHistory = [...messages, userMessage] // Include the just-added user message
|
||||
.map((msg) => ({ role: msg.role, text: msg.content }));
|
||||
|
||||
try {
|
||||
// Call the API function with context
|
||||
const response = await sendChatMessage(
|
||||
userMessage.content,
|
||||
apiHistory,
|
||||
farmId, // Pass farmId
|
||||
cropId // Pass cropId
|
||||
);
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: response.response,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
// Replace loading state with the actual response
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error("Error sending chat message:", error);
|
||||
const errorMessage: Message = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: `Sorry, something went wrong. ${(error as Error).message || "Please try again."}`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
// Replace loading state with the error message
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false); // Hide loading indicator
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Farming Assistant Chat</DialogTitle>
|
||||
<DialogTitle>Farming Assistant Chat for {cropName}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<DialogContent className="sm:max-w-[500px] p-0 dark:bg-background">
|
||||
<div className="flex flex-col h-[600px]">
|
||||
<div className="p-4 border-b dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold">Farming Assistant</h2>
|
||||
<p className="text-sm text-muted-foreground">Ask questions about your {cropName}</p>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-green-600" />
|
||||
Farming Assistant
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Ask about {cropName}</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, i) => (
|
||||
<div key={i} className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
{messages.map(
|
||||
(
|
||||
message // Render existing messages
|
||||
) => (
|
||||
<div
|
||||
className={`rounded-lg px-4 py-2 max-w-[80%] ${
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground"
|
||||
: "bg-muted dark:bg-muted dark:text-muted-foreground"
|
||||
}`}>
|
||||
{message.content}
|
||||
key={message.id}
|
||||
className={`flex items-start gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
|
||||
{message.role === "assistant" && (
|
||||
<Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0 shadow-sm">
|
||||
<AvatarFallback>
|
||||
<Bot className="h-5 w-5 text-green-600" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div
|
||||
className={`rounded-lg px-4 py-2 max-w-[80%] shadow-sm ${
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground"
|
||||
: "bg-muted dark:bg-muted dark:text-muted-foreground"
|
||||
}`}>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{message.content}</p>
|
||||
<p
|
||||
className={`mt-1 text-xs opacity-70 ${
|
||||
message.role === "user" ? "text-green-100" : "text-gray-500 dark:text-gray-400"
|
||||
} text-right`}>
|
||||
{message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
</div>
|
||||
{message.role === "user" && (
|
||||
<Avatar className="h-8 w-8 border bg-gray-200 dark:bg-gray-600 flex-shrink-0 shadow-sm">
|
||||
<AvatarFallback>
|
||||
<User className="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{/* Conditional Loading Indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0">
|
||||
<AvatarFallback>
|
||||
<Bot className="h-5 w-5 text-green-600" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted dark:bg-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Assistant is thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@ -76,9 +187,22 @@ export function ChatbotDialog({ open, onOpenChange, cropName }: ChatbotDialogPro
|
||||
handleSend();
|
||||
}}
|
||||
className="flex gap-2">
|
||||
<Input placeholder="Type your message..." value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
<Button type="submit" size="icon">
|
||||
<Send className="h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Type your message..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={isLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
className="h-11"
|
||||
/>
|
||||
<Button type="submit" size="icon" disabled={isLoading || !input.trim()} className="h-11 w-11">
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
<span className="sr-only">Send</span>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
LineChart,
|
||||
@ -11,7 +11,6 @@ import {
|
||||
Sun,
|
||||
ThermometerSun,
|
||||
Timer,
|
||||
ListCollapse,
|
||||
Leaf,
|
||||
CloudRain,
|
||||
Wind,
|
||||
@ -22,6 +21,7 @@ import {
|
||||
LeafIcon,
|
||||
History,
|
||||
Bot,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -35,16 +35,42 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import type { Cropland, CropAnalytics, Farm } from "@/types";
|
||||
import { getFarm } from "@/api/farm";
|
||||
import { getPlants, PlantResponse } from "@/api/plant";
|
||||
import { getCropById, fetchCropAnalytics } from "@/api/crop";
|
||||
// Import the updated API functions
|
||||
import { getCropById, fetchCropAnalytics, deleteCrop, updateCrop } from "@/api/crop";
|
||||
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
||||
import { toast } from "sonner";
|
||||
import { CropDialog } from "../../crop-dialog"; // Assuming CropDialog is in the parent directory
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// Define the expected shape of data coming from CropDialog for update
|
||||
// Excludes fields not sent in the PUT request body (uuid, farmId, createdAt, updatedAt)
|
||||
type CropUpdateData = Omit<Cropland, "uuid" | "farmId" | "createdAt" | "updatedAt">;
|
||||
|
||||
export default function CropDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ farmId: string; cropId: string }>();
|
||||
const { farmId, cropId } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
const [isEditCropOpen, setIsEditCropOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
// --- Fetch Farm Data ---
|
||||
const { data: farm, isLoading: isLoadingFarm } = useQuery<Farm>({
|
||||
@ -64,7 +90,7 @@ export default function CropDetailPage() {
|
||||
queryKey: ["crop", cropId],
|
||||
queryFn: () => getCropById(cropId),
|
||||
enabled: !!cropId,
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 60 * 1000, // Refetch more often than farm/plants
|
||||
});
|
||||
|
||||
// --- Fetch All Plants Data ---
|
||||
@ -76,7 +102,7 @@ export default function CropDetailPage() {
|
||||
} = useQuery<PlantResponse>({
|
||||
queryKey: ["plants"],
|
||||
queryFn: getPlants,
|
||||
staleTime: 1000 * 60 * 60,
|
||||
staleTime: 1000 * 60 * 60, // Plants data is relatively static
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@ -88,7 +114,7 @@ export default function CropDetailPage() {
|
||||
|
||||
// --- Fetch Crop Analytics Data ---
|
||||
const {
|
||||
data: analytics, // Type is CropAnalytics | null
|
||||
data: analytics,
|
||||
isLoading: isLoadingAnalytics,
|
||||
isError: isErrorAnalytics,
|
||||
error: errorAnalytics,
|
||||
@ -99,9 +125,66 @@ export default function CropDetailPage() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// --- Delete Crop Mutation ---
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteCrop(cropId), // Uses DELETE /crop/{cropId}
|
||||
onSuccess: () => {
|
||||
toast.success(`Crop "${cropland?.name}" deleted successfully.`);
|
||||
queryClient.invalidateQueries({ queryKey: ["crops", farmId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["farm", farmId] });
|
||||
queryClient.removeQueries({ queryKey: ["crop", cropId] });
|
||||
queryClient.removeQueries({ queryKey: ["cropAnalytics", cropId] });
|
||||
router.push(`/farms/${farmId}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to delete crop:", error);
|
||||
toast.error(`Failed to delete crop: ${(error as Error).message}`);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Update Crop Mutation ---
|
||||
// Updated to use the new updateCrop signature: updateCrop(cropId, payload)
|
||||
const updateMutation = useMutation({
|
||||
// dataFromDialog should contain the fields needed for the PUT request body
|
||||
mutationFn: async (dataFromDialog: CropUpdateData) => {
|
||||
if (!cropId) {
|
||||
throw new Error("Crop ID is missing for update.");
|
||||
}
|
||||
// Prepare the payload matching the UpdateCroplandInput body structure
|
||||
// Ensure all required fields for the PUT endpoint are present
|
||||
const updatePayload = {
|
||||
name: dataFromDialog.name,
|
||||
status: dataFromDialog.status,
|
||||
priority: dataFromDialog.priority ?? 0, // Use default or ensure it comes from dialog
|
||||
landSize: dataFromDialog.landSize ?? 0, // Use default or ensure it comes from dialog
|
||||
growthStage: dataFromDialog.growthStage,
|
||||
plantId: dataFromDialog.plantId,
|
||||
geoFeature: dataFromDialog.geoFeature,
|
||||
};
|
||||
// Call the API function with cropId and the prepared payload
|
||||
return updateCrop(cropId, updatePayload);
|
||||
},
|
||||
onSuccess: (updatedCrop) => {
|
||||
toast.success(`Crop "${updatedCrop.name}" updated successfully.`);
|
||||
// Invalidate queries to refetch data
|
||||
queryClient.invalidateQueries({ queryKey: ["crop", cropId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["crops", farmId] }); // Update list on farm page if name changed
|
||||
queryClient.invalidateQueries({ queryKey: ["farm", farmId] }); // Update farm details if needed
|
||||
queryClient.invalidateQueries({ queryKey: ["cropAnalytics", cropId] });
|
||||
setIsEditCropOpen(false); // Close the edit dialog
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update crop:", error);
|
||||
toast.error(`Failed to update crop: ${(error as Error).message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Combined Loading and Error States ---
|
||||
const isLoading = isLoadingFarm || isLoadingCropland || isLoadingPlants || isLoadingAnalytics;
|
||||
const isError = isErrorCropland || isErrorPlants || isErrorAnalytics; // Prioritize crop/analytics errors
|
||||
const isError = isErrorCropland || isErrorPlants || isErrorAnalytics;
|
||||
const error = errorCropland || errorPlants || errorAnalytics;
|
||||
|
||||
// --- Loading State ---
|
||||
@ -117,6 +200,9 @@ export default function CropDetailPage() {
|
||||
// --- Error State ---
|
||||
if (isError || !cropland) {
|
||||
console.error("Error loading crop details:", error);
|
||||
const errorMessage = isErrorCropland
|
||||
? `Crop with ID ${cropId} not found or could not be loaded.`
|
||||
: (error as Error)?.message || "An unexpected error occurred.";
|
||||
return (
|
||||
<div className="min-h-screen container max-w-7xl p-6 mx-auto">
|
||||
<Button
|
||||
@ -129,11 +215,7 @@ export default function CropDetailPage() {
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error Loading Crop Details</AlertTitle>
|
||||
<AlertDescription>
|
||||
{isErrorCropland
|
||||
? `Crop with ID ${cropId} not found or could not be loaded.`
|
||||
: (error as Error)?.message || "An unexpected error occurred."}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
@ -144,8 +226,11 @@ export default function CropDetailPage() {
|
||||
good: "text-green-500 bg-green-50 dark:bg-green-900 border-green-200",
|
||||
warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900 border-yellow-200",
|
||||
critical: "text-red-500 bg-red-50 dark:bg-red-900 border-red-200",
|
||||
unknown: "text-gray-500 bg-gray-50 dark:bg-gray-900 border-gray-200", // Added for safety
|
||||
};
|
||||
const healthStatus = analytics?.plantHealth || "good";
|
||||
// Use a safe default if analytics or plantHealth is missing
|
||||
const healthStatus = (analytics?.plantHealth as keyof typeof healthColors) || "unknown";
|
||||
const healthColorClass = healthColors[healthStatus] || healthColors.unknown;
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
@ -154,6 +239,7 @@ export default function CropDetailPage() {
|
||||
description: "View detailed growth analytics",
|
||||
onClick: () => setIsAnalyticsOpen(true),
|
||||
color: "bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300",
|
||||
disabled: !analytics, // Disable if no analytics data
|
||||
},
|
||||
{
|
||||
title: "Chat Assistant",
|
||||
@ -162,35 +248,24 @@ export default function CropDetailPage() {
|
||||
onClick: () => setIsChatOpen(true),
|
||||
color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300",
|
||||
},
|
||||
{
|
||||
title: "Crop Details",
|
||||
icon: ListCollapse,
|
||||
description: "View detailed information",
|
||||
onClick: () => console.log("Details clicked - Placeholder"),
|
||||
color: "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
description: "Configure crop settings",
|
||||
onClick: () => console.log("Settings clicked - Placeholder"),
|
||||
color: "bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-300",
|
||||
},
|
||||
// Settings moved to dropdown
|
||||
];
|
||||
|
||||
const plantedDate = cropland.createdAt ? new Date(cropland.createdAt) : null;
|
||||
const daysToMaturity = plant?.daysToMaturity; // Use camelCase
|
||||
const daysToMaturity = plant?.daysToMaturity;
|
||||
const expectedHarvestDate =
|
||||
plantedDate && daysToMaturity ? new Date(plantedDate.getTime() + daysToMaturity * 24 * 60 * 60 * 1000) : null;
|
||||
plantedDate && typeof daysToMaturity === "number"
|
||||
? new Date(plantedDate.getTime() + daysToMaturity * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const growthProgress = analytics?.growthProgress ?? 0; // Get from analytics
|
||||
const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize} ha` : "N/A"; // Use camelCase
|
||||
const growthProgress = analytics?.growthProgress ?? 0;
|
||||
const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize.toFixed(2)} ha` : "N/A";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="container max-w-7xl p-6 mx-auto">
|
||||
{/* Breadcrumbs */}
|
||||
<nav className="flex items-center text-sm text-muted-foreground mb-4">
|
||||
<nav className="flex items-center text-sm text-muted-foreground mb-4 flex-wrap">
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary"
|
||||
@ -198,22 +273,25 @@ export default function CropDetailPage() {
|
||||
<Home className="h-3.5 w-3.5 mr-1" />
|
||||
Home
|
||||
</Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1" />
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary"
|
||||
onClick={() => router.push("/farms")}>
|
||||
Farms
|
||||
</Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1" />
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary max-w-[150px] truncate"
|
||||
title={farm?.name || "Farm"}
|
||||
onClick={() => router.push(`/farms/${farmId}`)}>
|
||||
{farm?.name || "Farm"} {/* Use camelCase */}
|
||||
{farm?.name || "Farm"}
|
||||
</Button>
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1" />
|
||||
<span className="text-foreground font-medium truncate">{cropland.name || "Crop"}</span> {/* Use camelCase */}
|
||||
<ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
|
||||
<span className="text-foreground font-medium truncate" title={cropland.name || "Crop"}>
|
||||
{cropland.name || "Crop"}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
@ -226,21 +304,40 @@ export default function CropDetailPage() {
|
||||
onClick={() => router.push(`/farms/${farmId}`)}>
|
||||
<ArrowLeft className="h-4 w-4" /> Back to Farm
|
||||
</Button>
|
||||
{/* Hover Card (removed for simplicity, add back if needed) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Crop Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setIsEditCropOpen(true)}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Edit Crop</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:bg-red-50 focus:text-red-700"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||
<span>Delete Crop</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1> {/* Use camelCase */}
|
||||
<h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{plant?.variety || "Unknown Variety"} • {displayArea} {/* Use camelCase */}
|
||||
{plant?.variety || "Unknown Variety"} • {displayArea}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={`${healthColors[healthStatus]} border capitalize`}>
|
||||
{cropland.status} {/* Use camelCase */}
|
||||
<Badge variant="outline" className={`${healthColorClass} border capitalize`}>
|
||||
{cropland.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{expectedHarvestDate ? (
|
||||
@ -260,23 +357,28 @@ export default function CropDetailPage() {
|
||||
{/* Left Column */}
|
||||
<div className="md:col-span-8 space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{quickActions.map((action) => (
|
||||
<Button
|
||||
key={action.title}
|
||||
variant="outline"
|
||||
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105 border-border/30`}
|
||||
disabled={action.disabled}
|
||||
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${
|
||||
action.disabled ? "opacity-50 cursor-not-allowed" : `${action.color} hover:scale-105`
|
||||
} border-border/30`}
|
||||
onClick={action.onClick}>
|
||||
<div
|
||||
className={`p-3 rounded-lg ${action.color.replace(
|
||||
"text-",
|
||||
"bg-"
|
||||
)}/20 group-hover:scale-110 transition-transform`}>
|
||||
className={`p-3 rounded-lg ${
|
||||
action.disabled ? "bg-muted" : `${action.color.replace("text-", "bg-")}/20`
|
||||
} group-hover:scale-110 transition-transform`}>
|
||||
<action.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium mb-1">{action.title}</div>
|
||||
<p className="text-xs text-muted-foreground">{action.description}</p>
|
||||
{action.disabled && action.title === "Analytics" && (
|
||||
<p className="text-xs text-amber-600 mt-1">(No data)</p>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
@ -286,51 +388,70 @@ export default function CropDetailPage() {
|
||||
<Card className="border-border/30">
|
||||
<CardHeader>
|
||||
<CardTitle>Environmental Conditions</CardTitle>
|
||||
<CardDescription>Real-time monitoring data</CardDescription>
|
||||
<CardDescription>Real-time monitoring data (if available)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
// ... (metric definitions remain the same)
|
||||
{
|
||||
icon: ThermometerSun,
|
||||
label: "Temperature",
|
||||
value: analytics?.temperature ? `${analytics.temperature}°C` : "N/A",
|
||||
value:
|
||||
analytics?.temperature !== null && analytics?.temperature !== undefined
|
||||
? `${analytics.temperature.toFixed(1)}°C`
|
||||
: "N/A",
|
||||
color: "text-orange-500 dark:text-orange-300",
|
||||
bg: "bg-orange-50 dark:bg-orange-900",
|
||||
},
|
||||
{
|
||||
icon: Droplets,
|
||||
label: "Humidity",
|
||||
value: analytics?.humidity ? `${analytics.humidity}%` : "N/A",
|
||||
value:
|
||||
analytics?.humidity !== null && analytics?.humidity !== undefined
|
||||
? `${analytics.humidity.toFixed(0)}%`
|
||||
: "N/A",
|
||||
color: "text-blue-500 dark:text-blue-300",
|
||||
bg: "bg-blue-50 dark:bg-blue-900",
|
||||
},
|
||||
{
|
||||
icon: Sun,
|
||||
label: "Sunlight",
|
||||
value: analytics?.sunlight ? `${analytics.sunlight}%` : "N/A",
|
||||
value:
|
||||
analytics?.sunlight !== null && analytics?.sunlight !== undefined
|
||||
? `${analytics.sunlight.toFixed(0)}%`
|
||||
: "N/A",
|
||||
color: "text-yellow-500 dark:text-yellow-300",
|
||||
bg: "bg-yellow-50 dark:bg-yellow-900",
|
||||
},
|
||||
{
|
||||
icon: Leaf,
|
||||
label: "Soil Moisture",
|
||||
value: analytics?.soilMoisture ? `${analytics.soilMoisture}%` : "N/A",
|
||||
value:
|
||||
analytics?.soilMoisture !== null && analytics?.soilMoisture !== undefined
|
||||
? `${analytics.soilMoisture.toFixed(0)}%`
|
||||
: "N/A",
|
||||
color: "text-green-500 dark:text-green-300",
|
||||
bg: "bg-green-50 dark:bg-green-900",
|
||||
},
|
||||
{
|
||||
icon: Wind,
|
||||
label: "Wind Speed",
|
||||
value: analytics?.windSpeed ?? "N/A",
|
||||
value:
|
||||
analytics?.windSpeed !== null && analytics?.windSpeed !== undefined
|
||||
? `${analytics.windSpeed.toFixed(1)} m/s`
|
||||
: "N/A",
|
||||
color: "text-gray-500 dark:text-gray-300",
|
||||
bg: "bg-gray-50 dark:bg-gray-900",
|
||||
},
|
||||
{
|
||||
icon: CloudRain,
|
||||
label: "Rainfall",
|
||||
value: analytics?.rainfall ?? "N/A",
|
||||
label: "Rainfall (1h)",
|
||||
value:
|
||||
analytics?.rainfall !== null && analytics?.rainfall !== undefined
|
||||
? `${analytics.rainfall.toFixed(1)} mm`
|
||||
: "N/A",
|
||||
color: "text-indigo-500 dark:text-indigo-300",
|
||||
bg: "bg-indigo-50 dark:bg-indigo-900",
|
||||
},
|
||||
@ -350,42 +471,50 @@ export default function CropDetailPage() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
{/* Growth Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Growth Progress</span>
|
||||
<span className="text-muted-foreground">{growthProgress}%</span>
|
||||
</div>
|
||||
<Progress value={growthProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity.
|
||||
</p>
|
||||
</div>
|
||||
{/* Next Action Card */}
|
||||
<Card className="border-blue-100 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-900/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-800">
|
||||
<Timer className="h-4 w-4 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-1">Next Action Required</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{analytics?.nextAction || "Check crop status"}
|
||||
</p>
|
||||
{analytics?.nextActionDue && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Due by {new Date(analytics.nextActionDue).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
{!analytics?.nextAction && (
|
||||
<p className="text-xs text-muted-foreground mt-1">No immediate actions required.</p>
|
||||
)}
|
||||
{/* Show message if no analytics at all */}
|
||||
{!analytics && !isLoadingAnalytics && (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">Environmental data not available.</p>
|
||||
)}
|
||||
{analytics && (
|
||||
<>
|
||||
<Separator />
|
||||
{/* Growth Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Growth Progress</span>
|
||||
<span className="text-muted-foreground">{growthProgress}%</span>
|
||||
</div>
|
||||
<Progress value={growthProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Next Action Card */}
|
||||
<Card className="border-blue-100 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-900/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-800">
|
||||
<Timer className="h-4 w-4 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-1">Next Action Required</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{analytics?.nextAction || "Check crop status"}
|
||||
</p>
|
||||
{analytics?.nextActionDue && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Due by {new Date(analytics.nextActionDue).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
{!analytics?.nextAction && (
|
||||
<p className="text-xs text-muted-foreground mt-1">No immediate actions required.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -397,11 +526,11 @@ export default function CropDetailPage() {
|
||||
<CardDescription>Visual representation on the farm</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 h-[400px] overflow-hidden rounded-b-lg">
|
||||
{/* TODO: Ensure GoogleMapWithDrawing correctly parses and displays GeoFeatureData */}
|
||||
<GoogleMapWithDrawing
|
||||
initialFeatures={cropland.geoFeature ? [cropland.geoFeature] : undefined}
|
||||
drawingMode={null}
|
||||
editable={false}
|
||||
initialCenter={farm ? { lat: farm.lat, lng: farm.lon } : undefined}
|
||||
initialZoom={15}
|
||||
displayOnly={true}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -420,37 +549,39 @@ export default function CropDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
name: "Nitrogen (N)",
|
||||
value: analytics?.nutrientLevels?.nitrogen,
|
||||
color: "bg-blue-500 dark:bg-blue-700",
|
||||
},
|
||||
{
|
||||
name: "Phosphorus (P)",
|
||||
value: analytics?.nutrientLevels?.phosphorus,
|
||||
color: "bg-yellow-500 dark:bg-yellow-700",
|
||||
},
|
||||
{
|
||||
name: "Potassium (K)",
|
||||
value: analytics?.nutrientLevels?.potassium,
|
||||
color: "bg-green-500 dark:bg-green-700",
|
||||
},
|
||||
].map((nutrient) => (
|
||||
<div key={nutrient.name} className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{nutrient.name}</span>
|
||||
<span className="text-muted-foreground">{nutrient.value ?? "N/A"}%</span>
|
||||
{/* Check if analytics and nutrientLevels exist before mapping */}
|
||||
{analytics?.nutrientLevels ? (
|
||||
[
|
||||
{
|
||||
name: "Nitrogen (N)",
|
||||
value: analytics.nutrientLevels.nitrogen,
|
||||
color: "bg-blue-500 dark:bg-blue-700",
|
||||
},
|
||||
{
|
||||
name: "Phosphorus (P)",
|
||||
value: analytics.nutrientLevels.phosphorus,
|
||||
color: "bg-yellow-500 dark:bg-yellow-700",
|
||||
},
|
||||
{
|
||||
name: "Potassium (K)",
|
||||
value: analytics.nutrientLevels.potassium,
|
||||
color: "bg-green-500 dark:bg-green-700",
|
||||
},
|
||||
].map((nutrient) => (
|
||||
<div key={nutrient.name} className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{nutrient.name}</span>
|
||||
<span className="text-muted-foreground">{nutrient.value ?? "N/A"}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={nutrient.value ?? 0}
|
||||
className={`h-2 ${
|
||||
nutrient.value !== null && nutrient.value !== undefined ? nutrient.color : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
value={nutrient.value ?? 0}
|
||||
className={`h-2 ${
|
||||
nutrient.value !== null && nutrient.value !== undefined ? nutrient.color : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!analytics?.nutrientLevels && (
|
||||
))
|
||||
) : (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">Nutrient data not available.</p>
|
||||
)}
|
||||
</div>
|
||||
@ -467,6 +598,7 @@ export default function CropDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[300px] pr-4">
|
||||
{/* Placeholder - Replace with actual activity log */}
|
||||
<div className="text-center py-10 text-muted-foreground">No recent activity logged.</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
@ -476,28 +608,52 @@ export default function CropDetailPage() {
|
||||
|
||||
{/* Dialogs */}
|
||||
<ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={cropland.name || "this crop"} />
|
||||
{/* Ensure AnalyticsDialog uses the correct props */}
|
||||
|
||||
{/* Conditionally render AnalyticsDialog only if analytics data exists */}
|
||||
{analytics && (
|
||||
<AnalyticsDialog
|
||||
open={isAnalyticsOpen}
|
||||
onOpenChange={setIsAnalyticsOpen}
|
||||
// The dialog expects a `Crop` type, but we have `Cropland` and `CropAnalytics`
|
||||
// We need to construct a simplified `Crop` object or update the dialog prop type
|
||||
crop={{
|
||||
// Constructing a simplified Crop object
|
||||
uuid: cropland.uuid,
|
||||
farmId: cropland.farmId,
|
||||
name: cropland.name,
|
||||
createdAt: cropland.createdAt, // Use createdAt as plantedDate
|
||||
status: cropland.status,
|
||||
variety: plant?.variety, // Get from plant data
|
||||
area: `${cropland.landSize} ha`, // Convert landSize
|
||||
progress: growthProgress, // Use calculated/fetched progress
|
||||
// healthScore might map to plantHealth
|
||||
}}
|
||||
analytics={analytics} // Pass fetched analytics
|
||||
crop={cropland} // Pass the full cropland object
|
||||
analytics={analytics} // Pass the analytics data
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Crop Dialog */}
|
||||
<CropDialog
|
||||
open={isEditCropOpen}
|
||||
onOpenChange={setIsEditCropOpen}
|
||||
initialData={cropland} // Pass current cropland data to pre-fill the form
|
||||
onSubmit={async (data) => {
|
||||
// 'data' from the dialog should match CropUpdateData structure
|
||||
await updateMutation.mutateAsync(data as CropUpdateData);
|
||||
}}
|
||||
isSubmitting={updateMutation.isPending}
|
||||
isEditing={true} // Indicate that this is for editing
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the crop "{cropland.name}" and all
|
||||
associated data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete Crop
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
242
frontend/app/(sidebar)/farms/edit-farm-form.tsx
Normal file
242
frontend/app/(sidebar)/farms/edit-farm-form.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { useCallback, useEffect } from "react"; // Added useEffect
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Farm } from "@/types";
|
||||
import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing";
|
||||
|
||||
// Schema for editing - make fields optional if needed, but usually same as create
|
||||
const farmFormSchema = z.object({
|
||||
name: z.string().min(2, "Farm name must be at least 2 characters"),
|
||||
latitude: z
|
||||
.number({ invalid_type_error: "Latitude must be a number" })
|
||||
.min(-90, "Invalid latitude")
|
||||
.max(90, "Invalid latitude")
|
||||
.refine((val) => val !== 0, { message: "Please select a location on the map." }),
|
||||
longitude: z
|
||||
.number({ invalid_type_error: "Longitude must be a number" })
|
||||
.min(-180, "Invalid longitude")
|
||||
.max(180, "Invalid longitude")
|
||||
.refine((val) => val !== 0, { message: "Please select a location on the map." }),
|
||||
type: z.string().min(1, "Please select a farm type"),
|
||||
area: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface EditFarmFormProps {
|
||||
initialData: Farm; // Require initial data for editing
|
||||
onSubmit: (data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) => Promise<void>; // Exclude non-editable fields
|
||||
onCancel: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function EditFarmForm({ initialData, onSubmit, onCancel, isSubmitting }: EditFarmFormProps) {
|
||||
const form = useForm<z.infer<typeof farmFormSchema>>({
|
||||
resolver: zodResolver(farmFormSchema),
|
||||
// Set default values from initialData
|
||||
defaultValues: {
|
||||
name: initialData.name || "",
|
||||
latitude: initialData.lat || 0,
|
||||
longitude: initialData.lon || 0,
|
||||
type: initialData.farmType || "",
|
||||
area: initialData.totalSize || "",
|
||||
},
|
||||
});
|
||||
|
||||
// Update form if initialData changes (e.g., opening dialog for different farms)
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
name: initialData.name || "",
|
||||
latitude: initialData.lat || 0,
|
||||
longitude: initialData.lon || 0,
|
||||
type: initialData.farmType || "",
|
||||
area: initialData.totalSize || "",
|
||||
});
|
||||
}, [initialData, form.reset]);
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof farmFormSchema>) => {
|
||||
try {
|
||||
// Shape data for the API update function
|
||||
const farmUpdateData: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">> = {
|
||||
name: values.name,
|
||||
lat: values.latitude,
|
||||
lon: values.longitude,
|
||||
farmType: values.type,
|
||||
totalSize: values.area,
|
||||
};
|
||||
await onSubmit(farmUpdateData);
|
||||
// No need to reset form here, dialog closing handles it or parent component does
|
||||
} catch (error) {
|
||||
console.error("Error submitting edit form:", error);
|
||||
// Error handled by mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
// Map handler - same as AddFarmForm
|
||||
const handleShapeDrawn = useCallback(
|
||||
(data: ShapeData) => {
|
||||
if (data.type === "marker") {
|
||||
const { lat, lng } = data.position;
|
||||
form.setValue("latitude", lat, { shouldValidate: true });
|
||||
form.setValue("longitude", lng, { shouldValidate: true });
|
||||
} else {
|
||||
console.log(`Shape type '${data.type}' ignored for coordinate update.`);
|
||||
}
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-6 p-4">
|
||||
{/* Form Section */}
|
||||
<div className="lg:flex-[1]">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
{/* Fields: Name, Lat/Lon, Type, Area - same structure as AddFarmForm */}
|
||||
{/* Farm Name Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Farm Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter farm name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>This is your farm's display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Coordinate Fields (Latitude & Longitude) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="latitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Latitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Select on map"
|
||||
{...field}
|
||||
value={field.value ? field.value.toFixed(6) : ""}
|
||||
disabled
|
||||
readOnly
|
||||
className="disabled:opacity-100 disabled:cursor-default"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="longitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Longitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Select on map"
|
||||
{...field}
|
||||
value={field.value ? field.value.toFixed(6) : ""}
|
||||
disabled
|
||||
readOnly
|
||||
className="disabled:opacity-100 disabled:cursor-default"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Farm Type Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Farm Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select farm type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="durian">Durian</SelectItem>
|
||||
<SelectItem value="mango">Mango</SelectItem>
|
||||
<SelectItem value="rice">Rice</SelectItem>
|
||||
<SelectItem value="mixed">Mixed Crops</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Total Area Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="area"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Total Area (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., 10 hectares" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormDescription>The total size of your farm (e.g., "15 rai", "10 hectares").</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Submit and Cancel Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} className="bg-blue-600 hover:bg-blue-700">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
{/* Map Section */}
|
||||
<div className="lg:flex-[2] min-h-[400px] lg:min-h-0 flex flex-col">
|
||||
<FormLabel>Farm Location (Update marker if needed)</FormLabel>
|
||||
<div className="mt-2 rounded-md overflow-hidden border flex-grow">
|
||||
<GoogleMapWithDrawing
|
||||
onShapeDrawn={handleShapeDrawn}
|
||||
// Pass initial coordinates to center the map
|
||||
initialCenter={{ lat: initialData.lat, lng: initialData.lon }}
|
||||
initialZoom={15} // Or a suitable zoom level
|
||||
// You could potentially pass the existing farm marker as an initial feature:
|
||||
initialFeatures={[{ type: "marker", position: { lat: initialData.lat, lng: initialData.lon } }]}
|
||||
/>
|
||||
</div>
|
||||
<FormDescription className="mt-2">
|
||||
Click the marker tool and place a new marker to update coordinates.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,19 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card";
|
||||
import { MapPin, Sprout, Plus, CalendarDays, ArrowRight } from "lucide-react";
|
||||
import { MapPin, Sprout, Plus, ArrowRight, MoreVertical, Edit, Trash2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Farm } from "@/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export interface FarmCardProps {
|
||||
variant: "farm" | "add";
|
||||
farm?: Farm; // Use updated Farm type
|
||||
onClick?: () => void;
|
||||
onEditClick?: (e: React.MouseEvent) => void; // Callback for edit
|
||||
onDeleteClick?: (e: React.MouseEvent) => void; // Callback for delete
|
||||
}
|
||||
|
||||
export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
|
||||
export function FarmCard({ variant, farm, onClick, onEditClick, onDeleteClick }: FarmCardProps) {
|
||||
const cardClasses = cn(
|
||||
"w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border",
|
||||
variant === "add"
|
||||
@ -21,6 +30,10 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
|
||||
: "bg-white dark:bg-slate-800 hover:bg-muted/10 dark:hover:bg-slate-700 border-muted/60"
|
||||
);
|
||||
|
||||
// Stop propagation for dropdown menu trigger and items
|
||||
const stopPropagation = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
if (variant === "add") {
|
||||
return (
|
||||
<Card className={cardClasses} onClick={onClick}>
|
||||
@ -43,49 +56,81 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
|
||||
}).format(new Date(farm.createdAt));
|
||||
|
||||
return (
|
||||
<Card className={cardClasses} onClick={onClick}>
|
||||
<Card className={cardClasses}>
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200">
|
||||
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200 flex-shrink-0">
|
||||
{farm.farmType}
|
||||
</Badge>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3 w-3 mr-1" />
|
||||
{formattedDate}
|
||||
</div>
|
||||
{/* Actions Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={stopPropagation}>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:bg-muted/50">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Farm Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={stopPropagation}>
|
||||
<DropdownMenuItem onClick={onEditClick}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit Farm</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||
onClick={onDeleteClick}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete Farm</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center">
|
||||
<Sprout className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-1 truncate">{farm.name}</h3>
|
||||
<div className="flex items-center text-sm text-muted-foreground mb-2">
|
||||
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{farm.lat}</span>
|
||||
{/* Use div for clickable area if needed, or rely on button */}
|
||||
<div className="flex-grow cursor-pointer" onClick={onClick}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center bg-muted/40">
|
||||
<Sprout className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">Area</p>
|
||||
<p className="font-medium">{farm.totalSize}</p>
|
||||
{/* Ensure text truncates */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-medium mb-1 truncate" title={farm.name}>
|
||||
{farm.name}
|
||||
</h3>
|
||||
<div className="flex items-center text-sm text-muted-foreground mb-2 truncate">
|
||||
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
|
||||
{/* Display truncated location or just Lat/Lon */}
|
||||
<span className="truncate" title={`Lat: ${farm.lat}, Lon: ${farm.lon}`}>
|
||||
Lat: {farm.lat.toFixed(3)}, Lon: {farm.lon.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">Crops</p>
|
||||
<p className="font-medium">{farm.crops ? farm.crops.length : 0}</p>
|
||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">Area</p>
|
||||
<p className="font-medium truncate" title={farm.totalSize || "N/A"}>
|
||||
{farm.totalSize || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">Crops</p>
|
||||
<p className="font-medium">{farm.crops ? farm.crops.length : 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
</CardContent>
|
||||
</div>
|
||||
<CardFooter className="p-4 pt-0 mt-auto">
|
||||
{" "}
|
||||
{/* Keep footer outside clickable area */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto gap-1 text-green-600 hover:text-green-700 hover:bg-green-50/50 dark:hover:bg-green-800">
|
||||
className="ml-auto gap-1 text-primary hover:text-primary/80 hover:bg-primary/10"
|
||||
onClick={onClick}>
|
||||
View details <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
||||
@ -20,11 +20,25 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
import { FarmCard } from "./farm-card";
|
||||
import { AddFarmForm } from "./add-farm-form";
|
||||
import { EditFarmForm } from "./edit-farm-form";
|
||||
import type { Farm } from "@/types";
|
||||
import { fetchFarms, createFarm } from "@/api/farm";
|
||||
import { fetchFarms, createFarm, updateFarm, deleteFarm } from "@/api/farm";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function FarmSetupPage() {
|
||||
const router = useRouter();
|
||||
@ -33,27 +47,68 @@ export default function FarmSetupPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<string>("all");
|
||||
const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); // State for edit dialog
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); // State for delete dialog
|
||||
const [selectedFarm, setSelectedFarm] = useState<Farm | null>(null); // Farm to edit/delete
|
||||
|
||||
// --- Fetch Farms ---
|
||||
const {
|
||||
data: farms, // Type is Farm[] now
|
||||
data: farms,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<Farm[]>({
|
||||
// Use Farm[] type
|
||||
queryKey: ["farms"],
|
||||
queryFn: fetchFarms,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
// Pass the correct type to createFarm
|
||||
// --- Create Farm Mutation ---
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) =>
|
||||
createFarm(data),
|
||||
onSuccess: () => {
|
||||
onSuccess: (newFarm) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["farms"] });
|
||||
setIsDialogOpen(false);
|
||||
setIsAddDialogOpen(false);
|
||||
toast.success(`Farm "${newFarm.name}" created successfully!`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to create farm: ${(error as Error).message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Update Farm Mutation ---
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
farmId: string;
|
||||
payload: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>;
|
||||
}) => updateFarm(data.farmId, data.payload),
|
||||
onSuccess: (updatedFarm) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["farms"] });
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedFarm(null);
|
||||
toast.success(`Farm "${updatedFarm.name}" updated successfully!`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update farm: ${(error as Error).message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Delete Farm Mutation ---
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (farmId: string) => deleteFarm(farmId),
|
||||
onSuccess: (_, farmId) => {
|
||||
// Second arg is the variable passed to mutate
|
||||
queryClient.invalidateQueries({ queryKey: ["farms"] });
|
||||
// Optionally remove specific farm query if cached elsewhere: queryClient.removeQueries({ queryKey: ["farm", farmId] });
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedFarm(null);
|
||||
toast.success(`Farm deleted successfully.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete farm: ${(error as Error).message}`);
|
||||
setIsDeleteDialogOpen(false); // Close dialog even on error
|
||||
},
|
||||
});
|
||||
|
||||
@ -69,6 +124,35 @@ export default function FarmSetupPage() {
|
||||
// UpdatedAt: string;
|
||||
// }
|
||||
|
||||
const handleAddFarmSubmit = async (data: Partial<Farm>) => {
|
||||
await createMutation.mutateAsync(data);
|
||||
};
|
||||
|
||||
const handleEditFarmSubmit = async (
|
||||
data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>
|
||||
) => {
|
||||
if (!selectedFarm) return;
|
||||
await updateMutation.mutateAsync({ farmId: selectedFarm.uuid, payload: data });
|
||||
};
|
||||
|
||||
const openEditDialog = (farm: Farm, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent card click
|
||||
setSelectedFarm(farm);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const openDeleteDialog = (farm: Farm, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent card click
|
||||
setSelectedFarm(farm);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!selectedFarm) return;
|
||||
deleteMutation.mutate(selectedFarm.uuid);
|
||||
};
|
||||
|
||||
// --- Filtering and Sorting Logic ---
|
||||
const filteredAndSortedFarms = (farms || [])
|
||||
.filter(
|
||||
(farm) =>
|
||||
@ -90,10 +174,6 @@ export default function FarmSetupPage() {
|
||||
// Get distinct farm types.
|
||||
const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.farmType))]; // Use camelCase farmType
|
||||
|
||||
const handleAddFarm = async (data: Partial<Farm>) => {
|
||||
await mutation.mutateAsync(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b">
|
||||
<div className="container max-w-7xl p-6 mx-auto">
|
||||
@ -114,7 +194,7 @@ export default function FarmSetupPage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setIsDialogOpen(true)} className="gap-2 bg-green-600 hover:bg-green-700">
|
||||
<Button onClick={() => setIsAddDialogOpen(true)} className="gap-2 bg-green-600 hover:bg-green-700">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Farm
|
||||
</Button>
|
||||
@ -128,8 +208,9 @@ export default function FarmSetupPage() {
|
||||
<Badge
|
||||
key={type}
|
||||
variant={activeFilter === type ? "default" : "outline"}
|
||||
className={`capitalize cursor-pointer ${
|
||||
activeFilter === type ? "bg-green-600" : "hover:bg-green-100"
|
||||
className={`capitalize cursor-pointer rounded-full px-3 py-1 text-sm ${
|
||||
// Made rounded-full
|
||||
activeFilter === type ? "bg-primary text-primary-foreground" : "hover:bg-accent" // Adjusted colors
|
||||
}`}
|
||||
onClick={() => setActiveFilter(type)}>
|
||||
{type === "all" ? "All Farms" : type}
|
||||
@ -148,25 +229,25 @@ export default function FarmSetupPage() {
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className={sortOrder === "newest" ? "bg-green-50" : ""}
|
||||
className={sortOrder === "newest" ? "bg-accent" : ""} // Use accent for selection
|
||||
onClick={() => setSortOrder("newest")}>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Newest first
|
||||
{sortOrder === "newest" && <Check className="h-4 w-4 ml-2" />}
|
||||
{sortOrder === "newest" && <Check className="h-4 w-4 ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={sortOrder === "oldest" ? "bg-green-50" : ""}
|
||||
className={sortOrder === "oldest" ? "bg-accent" : ""}
|
||||
onClick={() => setSortOrder("oldest")}>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Oldest first
|
||||
{sortOrder === "oldest" && <Check className="h-4 w-4 ml-2" />}
|
||||
{sortOrder === "oldest" && <Check className="h-4 w-4 ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={sortOrder === "alphabetical" ? "bg-green-50" : ""}
|
||||
className={sortOrder === "alphabetical" ? "bg-accent" : ""}
|
||||
onClick={() => setSortOrder("alphabetical")}>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Alphabetical
|
||||
{sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-2" />}
|
||||
{sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@ -178,21 +259,40 @@ export default function FarmSetupPage() {
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>Error Loading Farms</AlertTitle>
|
||||
<AlertDescription>{(error as Error)?.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Loading your farms...</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map(
|
||||
(
|
||||
_,
|
||||
i // Render skeleton cards
|
||||
) => (
|
||||
<Card key={i} className="w-full h-[250px]">
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<Skeleton className="h-6 w-2/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
<Skeleton className="h-8 w-24 ml-auto" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isError && filteredAndSortedFarms.length === 0 && (
|
||||
// ... (Empty state remains the same) ...
|
||||
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 rounded-lg border border-dashed">
|
||||
<div className="bg-green-100 p-3 rounded-full mb-4">
|
||||
<Leaf className="h-6 w-6 text-green-600" />
|
||||
@ -204,7 +304,7 @@ export default function FarmSetupPage() {
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center max-w-md mb-6">
|
||||
You haven't added any farms yet. Get started by adding your first farm.
|
||||
You haven't added any farms yet. Get started by adding your first farm.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
@ -212,7 +312,7 @@ export default function FarmSetupPage() {
|
||||
setSearchQuery("");
|
||||
setActiveFilter("all");
|
||||
if (!farms || farms.length === 0) {
|
||||
setIsDialogOpen(true);
|
||||
setIsAddDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
className="gap-2">
|
||||
@ -232,17 +332,31 @@ export default function FarmSetupPage() {
|
||||
{!isLoading && !isError && filteredAndSortedFarms.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence>
|
||||
<motion.div /* ... */>
|
||||
<FarmCard variant="add" onClick={() => setIsDialogOpen(true)} />
|
||||
{/* Add Farm Card */}
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}>
|
||||
<FarmCard variant="add" onClick={() => setIsAddDialogOpen(true)} />
|
||||
</motion.div>
|
||||
{/* Existing Farm Cards */}
|
||||
{filteredAndSortedFarms.map((farm, index) => (
|
||||
<motion.div
|
||||
key={farm.uuid} // Use camelCase uuid initial={{ opacity: 0, y: 20 }}
|
||||
layout // Add layout animation
|
||||
key={farm.uuid}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="col-span-1">
|
||||
<FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.uuid}`)} />
|
||||
<FarmCard
|
||||
variant="farm"
|
||||
farm={farm}
|
||||
onClick={() => router.push(`/farms/${farm.uuid}`)}
|
||||
onEditClick={(e) => openEditDialog(farm, e)} // Pass handler
|
||||
onDeleteClick={(e) => openDeleteDialog(farm, e)} // Pass handler
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@ -252,16 +366,57 @@ export default function FarmSetupPage() {
|
||||
</div>
|
||||
|
||||
{/* Add Farm Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[800px] md:max-w-[900px] lg:max-w-[1000px] xl:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Farm</DialogTitle>
|
||||
<DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Pass handleAddFarm (which now expects Partial<Farm>) */}
|
||||
<AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} />
|
||||
<AddFarmForm onSubmit={handleAddFarmSubmit} onCancel={() => setIsAddDialogOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Farm Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[800px] md:max-w-[900px] lg:max-w-[1000px] xl:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Farm: {selectedFarm?.name}</DialogTitle>
|
||||
<DialogDescription>Update the details for this farm.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Create or use an EditFarmForm component */}
|
||||
{selectedFarm && (
|
||||
<EditFarmForm
|
||||
initialData={selectedFarm}
|
||||
onSubmit={handleEditFarmSubmit}
|
||||
onCancel={() => setIsEditDialogOpen(false)}
|
||||
isSubmitting={updateMutation.isPending} // Pass submitting state
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the farm "{selectedFarm?.name}" and all
|
||||
associated crops and data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete Farm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,9 +4,6 @@ import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
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";
|
||||
@ -16,8 +13,7 @@ export default function AppLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const pathname = usePathname();
|
||||
const currentPathname = extractRoute(pathname);
|
||||
// const pathname = usePathname();
|
||||
const form = useForm();
|
||||
|
||||
return (
|
||||
@ -31,7 +27,7 @@ export default function AppLayout({
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<ThemeToggle />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<DynamicBreadcrumb pathname={currentPathname} />
|
||||
{/* <DynamicBreadcrumb pathname={currentPathname} /> */}
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
|
||||
269
frontend/app/(sidebar)/profile/page.tsx
Normal file
269
frontend/app/(sidebar)/profile/page.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
// frontend/app/(sidebar)/profile/page.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, User as UserIcon, Mail, Save, X, Edit, Camera } from "lucide-react";
|
||||
|
||||
import { fetchUserMe, UserDataOutput } from "@/api/user"; // Fetch function
|
||||
import { updateUserProfile, UpdateUserProfileInput } from "@/api/profile"; // Update function
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton"; // For loading state
|
||||
|
||||
// Schema for editable fields
|
||||
const profileSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(30, "Username cannot exceed 30 characters")
|
||||
.optional() // Make it optional if user doesn't have one initially
|
||||
.or(z.literal("")), // Allow empty string
|
||||
});
|
||||
|
||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
|
||||
export default function ProfilePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Fetch current user data
|
||||
const {
|
||||
data: userData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<UserDataOutput>({
|
||||
queryKey: ["userMe"],
|
||||
queryFn: fetchUserMe,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
const user = userData?.user;
|
||||
|
||||
// Setup react-hook-form
|
||||
const form = useForm<ProfileFormData>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Populate form when user data loads or edit mode changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.reset({
|
||||
username: user.username || "",
|
||||
});
|
||||
}
|
||||
}, [user, isEditing, form.reset]);
|
||||
|
||||
// Mutation for updating profile
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateUserProfile,
|
||||
onSuccess: (updatedUser) => {
|
||||
toast.success("Profile updated successfully!");
|
||||
// Update the cache with the new user data
|
||||
queryClient.setQueryData(["userMe"], { user: updatedUser });
|
||||
setIsEditing(false); // Exit edit mode
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update profile: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditToggle = () => {
|
||||
if (isEditing) {
|
||||
// Reset form to original values if canceling edit
|
||||
form.reset({ username: user?.username || "" });
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
const onSubmit = (formData: ProfileFormData) => {
|
||||
const updatePayload: UpdateUserProfileInput = {};
|
||||
// Only include username if it actually changed
|
||||
if (formData.username !== undefined && formData.username !== (user?.username || "")) {
|
||||
// Allow setting to empty string if desired, or add validation to prevent it
|
||||
updatePayload.username = formData.username;
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length > 0) {
|
||||
mutation.mutate(updatePayload);
|
||||
} else {
|
||||
// No changes were made
|
||||
setIsEditing(false); // Just exit edit mode
|
||||
}
|
||||
};
|
||||
|
||||
// --- Render Logic ---
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Skeleton className="h-8 w-1/4 mb-4" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton className="h-20 w-20 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div className="p-6 text-destructive">Error loading profile: {(error as Error)?.message}</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <div className="p-6 text-muted-foreground">User data not found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">User Profile</h1>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Account Information</CardTitle>
|
||||
<CardDescription>View and manage your personal details.</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant={isEditing ? "outline" : "default"}
|
||||
size="sm"
|
||||
onClick={handleEditToggle}
|
||||
disabled={mutation.isPending}>
|
||||
{isEditing ? <X className="mr-2 h-4 w-4" /> : <Edit className="mr-2 h-4 w-4" />}
|
||||
{isEditing ? "Cancel" : "Edit Profile"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
{/* Avatar Section (Placeholder for upload) */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar className="h-24 w-24 border-2 border-primary/20">
|
||||
<AvatarImage
|
||||
src={user.avatar || `https://api.dicebear.com/9.x/initials/svg?seed=${user.email}`}
|
||||
alt={user.username || user.email}
|
||||
/>
|
||||
<AvatarFallback className="text-lg">
|
||||
{user.username ? user.username.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isEditing || mutation.isPending}
|
||||
onClick={() => toast.info("Avatar upload coming soon!")}>
|
||||
<Camera className="mr-2 h-3 w-3" /> Change
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 w-full">
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="username" className="flex items-center gap-1">
|
||||
<UserIcon className="h-4 w-4 text-muted-foreground" /> Username
|
||||
</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="Enter your username"
|
||||
{...field}
|
||||
readOnly={!isEditing}
|
||||
className={
|
||||
!isEditing
|
||||
? "border-none bg-transparent px-1 shadow-none read-only:focus-visible:ring-0 read-only:focus-visible:ring-offset-0"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email Field (Read-only) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email" className="flex items-center gap-1">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" /> Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={user.email}
|
||||
readOnly
|
||||
className="border-none bg-transparent px-1 shadow-none read-only:focus-visible:ring-0 read-only:focus-visible:ring-offset-0"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground px-1">Email cannot be changed currently.</p>
|
||||
</div>
|
||||
|
||||
{/* User ID (Read-only) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="userId" className="flex items-center gap-1">
|
||||
ID
|
||||
</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
value={user.uuid}
|
||||
readOnly
|
||||
className="border-none bg-transparent px-1 shadow-none text-xs text-muted-foreground read-only:focus-visible:ring-0 read-only:focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button (Visible only in edit mode) */}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={mutation.isPending || !form.formState.isDirty}>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" /> Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/app/(sidebar)/settings/page.tsx
Normal file
94
frontend/app/(sidebar)/settings/page.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Paintbrush, User, Trash2, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
toast.warning("Account deletion is not yet implemented.", {
|
||||
description: "This feature will be available in a future update.",
|
||||
action: { label: "Close", onClick: () => toast.dismiss() },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-8">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
|
||||
{/* Appearance Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Paintbrush className="h-5 w-5 text-primary" /> Appearance
|
||||
</CardTitle>
|
||||
<CardDescription>Customize the look and feel of the application.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<Label htmlFor="theme" className="whitespace-nowrap">
|
||||
Theme
|
||||
</Label>
|
||||
<Select value={theme} onValueChange={setTheme}>
|
||||
<SelectTrigger id="theme" className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Add other appearance settings here if needed */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" /> Account
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your account details and security.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Link href="/profile" passHref>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
<span>Edit Profile Information</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
onClick={() => toast.info("Password change coming soon!")}>
|
||||
<span>Change Password</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
<Separator className="my-4" />
|
||||
<CardFooter className="flex flex-col items-start gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-destructive">Danger Zone</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Permanently delete your account and all associated data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleDeleteAccount} className="gap-2">
|
||||
<Trash2 className="h-4 w-4" /> Delete Account
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,141 +1,147 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import PlantingDetailsForm from "./planting-detail-form";
|
||||
import HarvestDetailsForm from "./harvest-detail-form";
|
||||
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";
|
||||
// "use client";
|
||||
// import { useState } from "react";
|
||||
// import PlantingDetailsForm from "./planting-detail-form";
|
||||
// import HarvestDetailsForm from "./harvest-detail-form";
|
||||
// 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";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
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" },
|
||||
];
|
||||
// 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 [step, setStep] = useState(1);
|
||||
// const [plantingDetails, setPlantingDetails] = useState<PlantingSchema | null>(
|
||||
// null
|
||||
// );
|
||||
// const [harvestDetails, setHarvestDetails] = useState<HarvestSchema | null>(
|
||||
// null
|
||||
// );
|
||||
// const [mapData, setMapData] = useState<{ lat: number; lng: number }[] | null>(
|
||||
// null
|
||||
// );
|
||||
|
||||
// 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);
|
||||
// };
|
||||
|
||||
// const handleBack = () => {
|
||||
// setStep((prev) => prev - 1);
|
||||
// };
|
||||
|
||||
// const handleSubmit = () => {
|
||||
// if (!mapData) {
|
||||
// toast.warning("Please select an area on the map before submitting.", {
|
||||
// action: {
|
||||
// label: "Close",
|
||||
// onClick: () => toast.dismiss(),
|
||||
// },
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// console.log("Submitting:", { plantingDetails, harvestDetails, mapData });
|
||||
|
||||
// // send request to the server
|
||||
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="p-5">
|
||||
// {/* 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>
|
||||
|
||||
// <Separator className="mb-5" />
|
||||
|
||||
// {step === 1 && (
|
||||
// <>
|
||||
// <h2 className="text-xl text-center mb-5">Planting Details</h2>
|
||||
// <PlantingDetailsForm onChange={setPlantingDetails} />
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// {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>
|
||||
// );
|
||||
// }
|
||||
|
||||
export default function SetupPage() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [plantingDetails, setPlantingDetails] = useState<PlantingSchema | null>(
|
||||
null
|
||||
);
|
||||
const [harvestDetails, setHarvestDetails] = useState<HarvestSchema | null>(
|
||||
null
|
||||
);
|
||||
const [mapData, setMapData] = useState<{ lat: number; lng: number }[] | null>(
|
||||
null
|
||||
);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setStep((prev) => prev - 1);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!mapData) {
|
||||
toast.warning("Please select an area on the map before submitting.", {
|
||||
action: {
|
||||
label: "Close",
|
||||
onClick: () => toast.dismiss(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Submitting:", { plantingDetails, harvestDetails, mapData });
|
||||
|
||||
// send request to the server
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
{/* 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>
|
||||
|
||||
<Separator className="mb-5" />
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<h2 className="text-xl text-center mb-5">Planting Details</h2>
|
||||
<PlantingDetailsForm onChange={setPlantingDetails} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
// redirect to /farms
|
||||
redirect("/farms");
|
||||
}
|
||||
|
||||
@ -1,52 +1,160 @@
|
||||
// google-map-with-drawing.tsx
|
||||
import React from "react";
|
||||
import { ControlPosition, Map, MapControl } from "@vis.gl/react-google-maps";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Map, useMap, useMapsLibrary, MapControl, ControlPosition } from "@vis.gl/react-google-maps";
|
||||
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
|
||||
import { useDrawingManager } from "@/components/map-component/use-drawing-manager";
|
||||
import { GeoFeatureData, GeoPosition } from "@/types";
|
||||
|
||||
// Export the type so the form can use it
|
||||
export { type ShapeData };
|
||||
export type ShapeData = GeoFeatureData;
|
||||
|
||||
// Define props for the component
|
||||
interface GoogleMapWithDrawingProps {
|
||||
onShapeDrawn: (data: ShapeData) => void; // Callback prop
|
||||
// Add any other props you might need, e.g., initialCenter, initialZoom
|
||||
initialCenter?: { lat: number; lng: number };
|
||||
onShapeDrawn?: (data: GeoFeatureData) => void;
|
||||
initialCenter?: GeoPosition;
|
||||
initialZoom?: number;
|
||||
initialFeatures?: GeoFeatureData[] | null;
|
||||
drawingMode?: google.maps.drawing.OverlayType | null;
|
||||
editable?: boolean;
|
||||
displayOnly?: boolean;
|
||||
mapId?: string;
|
||||
}
|
||||
|
||||
// Rename DrawingExample to GoogleMapWithDrawing and accept props
|
||||
const GoogleMapWithDrawing = ({
|
||||
onShapeDrawn, // Destructure the callback prop
|
||||
initialCenter = { lat: 13.7563, lng: 100.5018 }, // Default center
|
||||
initialZoom = 10, // Default zoom
|
||||
const GoogleMapWithDrawingInternal = ({
|
||||
onShapeDrawn,
|
||||
initialCenter = { lat: 13.7563, lng: 100.5018 },
|
||||
initialZoom = 10,
|
||||
initialFeatures,
|
||||
drawingMode = null,
|
||||
editable = true,
|
||||
displayOnly = false,
|
||||
}: GoogleMapWithDrawingProps) => {
|
||||
// Pass the onShapeDrawn callback directly to the hook
|
||||
const map = useMap();
|
||||
const geometryLib = useMapsLibrary("geometry");
|
||||
const [drawnOverlays, setDrawnOverlays] = useState<
|
||||
(google.maps.Marker | google.maps.Polygon | google.maps.Polyline)[]
|
||||
>([]);
|
||||
const isMountedRef = useRef(false);
|
||||
|
||||
const drawingManager = useDrawingManager(onShapeDrawn);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !initialFeatures || initialFeatures.length === 0 || !geometryLib) return;
|
||||
if (isMountedRef.current && !displayOnly) return;
|
||||
|
||||
drawnOverlays.forEach((overlay) => overlay.setMap(null));
|
||||
const newOverlays: (google.maps.Marker | google.maps.Polygon | google.maps.Polyline)[] = [];
|
||||
const bounds = new google.maps.LatLngBounds();
|
||||
|
||||
initialFeatures.forEach((feature) => {
|
||||
if (!feature) return;
|
||||
|
||||
let overlay: google.maps.Marker | google.maps.Polygon | google.maps.Polyline | null = null;
|
||||
|
||||
try {
|
||||
if (feature.type === "marker" && feature.position) {
|
||||
const marker = new google.maps.Marker({
|
||||
position: feature.position,
|
||||
map: map,
|
||||
});
|
||||
bounds.extend(feature.position);
|
||||
overlay = marker;
|
||||
} else if (feature.type === "polygon" && feature.path && feature.path.length > 0) {
|
||||
const polygon = new google.maps.Polygon({
|
||||
paths: feature.path,
|
||||
map: map,
|
||||
strokeColor: "#FF0000",
|
||||
strokeOpacity: 0.8,
|
||||
strokeWeight: 2,
|
||||
fillColor: "#FF0000",
|
||||
fillOpacity: 0.35,
|
||||
});
|
||||
feature.path.forEach((pos) => bounds.extend(pos));
|
||||
overlay = polygon;
|
||||
} else if (feature.type === "polyline" && feature.path && feature.path.length > 0) {
|
||||
const polyline = new google.maps.Polyline({
|
||||
path: feature.path,
|
||||
map: map,
|
||||
strokeColor: "#0000FF",
|
||||
strokeOpacity: 1.0,
|
||||
strokeWeight: 3,
|
||||
});
|
||||
feature.path.forEach((pos) => bounds.extend(pos));
|
||||
overlay = polyline;
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
newOverlays.push(overlay);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error creating map overlay:", e, "Feature:", feature);
|
||||
}
|
||||
});
|
||||
|
||||
setDrawnOverlays(newOverlays);
|
||||
|
||||
if (newOverlays.length === 1 && initialFeatures[0]?.type === "marker") {
|
||||
map.setCenter(initialFeatures[0].position);
|
||||
map.setZoom(initialZoom + 4);
|
||||
} else if (!bounds.isEmpty()) {
|
||||
map.fitBounds(bounds);
|
||||
} else {
|
||||
map.setCenter(initialCenter);
|
||||
map.setZoom(initialZoom);
|
||||
}
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
newOverlays.forEach((overlay) => {
|
||||
try {
|
||||
overlay.setMap(null);
|
||||
} catch (e) {
|
||||
console.warn("Error removing overlay during cleanup:", e);
|
||||
}
|
||||
});
|
||||
setDrawnOverlays([]);
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [map, initialFeatures, geometryLib, displayOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (drawingManager) {
|
||||
drawingManager.setOptions({
|
||||
drawingControl: !displayOnly,
|
||||
drawingMode: displayOnly ? null : drawingMode,
|
||||
markerOptions: {
|
||||
draggable: !displayOnly && editable,
|
||||
},
|
||||
polygonOptions: {
|
||||
editable: !displayOnly && editable,
|
||||
draggable: !displayOnly && editable,
|
||||
},
|
||||
polylineOptions: {
|
||||
editable: !displayOnly && editable,
|
||||
draggable: !displayOnly && editable,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [drawingManager, displayOnly, drawingMode, editable]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Use props for map defaults */}
|
||||
<Map
|
||||
defaultZoom={initialZoom}
|
||||
defaultCenter={initialCenter}
|
||||
gestureHandling={"greedy"}
|
||||
disableDefaultUI={true}
|
||||
mapId={"YOUR_MAP_ID"} // Recommended: Add a Map ID
|
||||
mapId={"YOUR_MAP_ID"}
|
||||
/>
|
||||
|
||||
{/* Render controls only if drawingManager is available */}
|
||||
{drawingManager && (
|
||||
{!displayOnly && drawingManager && (
|
||||
<MapControl position={ControlPosition.TOP_LEFT}>
|
||||
{/* Pass drawingManager to UndoRedoControl */}
|
||||
<UndoRedoControl drawingManager={drawingManager} />
|
||||
{editable && <UndoRedoControl drawingManager={drawingManager} />}
|
||||
</MapControl>
|
||||
)}
|
||||
{/* The drawing controls (marker, polygon etc.) are added by useDrawingManager */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GoogleMapWithDrawing = (props: GoogleMapWithDrawingProps) => {
|
||||
return <GoogleMapWithDrawingInternal {...props} />;
|
||||
};
|
||||
|
||||
export default GoogleMapWithDrawing;
|
||||
|
||||
@ -10,21 +10,21 @@ import {
|
||||
GalleryVerticalEnd,
|
||||
Map,
|
||||
PieChart,
|
||||
Settings2,
|
||||
Settings,
|
||||
SquareTerminal,
|
||||
User,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { NavMain } from "./nav-main";
|
||||
import { NavUser } from "./nav-user";
|
||||
import { TeamSwitcher } from "./team-switcher";
|
||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail } from "@/components/ui/sidebar";
|
||||
import { NavCrops } from "./nav-crops";
|
||||
import { Sidebar, SidebarContent, SidebarHeader, SidebarRail } from "@/components/ui/sidebar";
|
||||
// import { NavCrops } from "./nav-crops";
|
||||
import { fetchUserMe } from "@/api/user";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface Team {
|
||||
name: string;
|
||||
logo: React.ComponentType;
|
||||
logo: React.ComponentType<{ className?: string }>; // Ensure logo type accepts className
|
||||
plan: string;
|
||||
}
|
||||
|
||||
@ -34,12 +34,13 @@ interface NavItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
isActive?: boolean; // Add isActive property
|
||||
}
|
||||
|
||||
interface SidebarConfig {
|
||||
teams: Team[];
|
||||
navMain: NavItem[];
|
||||
crops: NavItem[];
|
||||
// crops: NavItem[];
|
||||
}
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
@ -69,27 +70,28 @@ function UserErrorFallback({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const defaultNavMain: NavItem[] = [
|
||||
{ title: "Farms", url: "/farms", icon: Map },
|
||||
{ title: "Inventory", url: "/inventory", icon: SquareTerminal },
|
||||
{ title: "Marketplace", url: "/marketplace", icon: PieChart }, // Updated title and icon
|
||||
{ title: "Knowledge Hub", url: "/hub", icon: BookOpen },
|
||||
{ title: "AI Chatbot", url: "/chatbot", icon: Bot },
|
||||
{ title: "Profile", url: "/profile", icon: UserCircle }, // Added Profile
|
||||
{ title: "Settings", url: "/settings", icon: Settings }, // Kept Settings
|
||||
];
|
||||
|
||||
export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const defaultConfig: SidebarConfig = {
|
||||
teams: [
|
||||
{ name: "Farm 1", logo: GalleryVerticalEnd, plan: "Hatyai" },
|
||||
{ name: "Farm 2", logo: AudioWaveform, plan: "Songkla" },
|
||||
{ name: "Farm 3", logo: Command, plan: "Layong" },
|
||||
],
|
||||
navMain: [
|
||||
{ title: "Farms", url: "/farms", icon: Map },
|
||||
{ title: "Inventory", url: "/inventory", icon: SquareTerminal },
|
||||
{ title: "Marketplace Information", url: "/marketplace", icon: PieChart },
|
||||
{ title: "Knowledge Hub", url: "/hub", icon: BookOpen },
|
||||
{ title: "Users", url: "/users", icon: User },
|
||||
{ title: "AI Chatbot", url: "/chatbot", icon: Bot },
|
||||
{ title: "Settings", url: "/settings", icon: Settings2 },
|
||||
],
|
||||
crops: [
|
||||
{ title: "Crops 1", url: "/farms/[farmId]/crops/1", icon: Map },
|
||||
{ title: "Crops 2", url: "/farms/[farmId]/crops/2", icon: Map },
|
||||
{ title: "Crops 3", url: "/farms/[farmId]/crops/3", icon: Map },
|
||||
],
|
||||
navMain: defaultNavMain.map((item) => ({
|
||||
...item,
|
||||
isActive: pathname.startsWith(item.url) && (item.url !== "/" || pathname === "/"),
|
||||
})),
|
||||
};
|
||||
|
||||
// Allow external configuration override
|
||||
@ -107,17 +109,18 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
||||
async function getUser() {
|
||||
try {
|
||||
const data = await fetchUserMe();
|
||||
console.log(data);
|
||||
console.log("Fetched user data:", data);
|
||||
setUser({
|
||||
name: data.user.uuid,
|
||||
name: data.user.username || data.user.email.split("@")[0] || `User ${data.user.uuid.substring(0, 6)}`,
|
||||
email: data.user.email,
|
||||
avatar: data.user.avatar || "/avatars/avatar.webp",
|
||||
avatar: data.user.avatar || `https://api.dicebear.com/9.x/initials/svg?seed=${data.user.email}`,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to fetch user for sidebar:", err);
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
setError("An unexpected error occurred fetching user data");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -129,17 +132,14 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<TeamSwitcher teams={sidebarConfig.teams} />
|
||||
{loading ? <UserSkeleton /> : error ? <UserErrorFallback message={error} /> : <NavUser user={user} />}
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={sidebarConfig.navMain} />
|
||||
<div className="mt-6">
|
||||
<NavCrops crops={sidebarConfig.crops} />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{/* <SidebarFooter>
|
||||
{loading ? <UserSkeleton /> : error ? <UserErrorFallback message={error} /> : <NavUser user={user} />}
|
||||
</SidebarFooter>
|
||||
</SidebarFooter> */}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
141
frontend/components/ui/alert-dialog.tsx
Normal file
141
frontend/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.0.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user