diff --git a/backend/go.mod b/backend/go.mod index ab3fb1f..c8436f4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,6 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 github.com/pressly/goose/v3 v3.24.1 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 diff --git a/backend/go.sum b/backend/go.sum index 633f502..d0cbd55 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -46,6 +46,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index c254bab..9b69188 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -23,6 +23,7 @@ type api struct { httpClient *http.Client userRepo domain.UserRepository + farmRepo domain.FarmRepository } func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api { @@ -30,12 +31,14 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api { client := &http.Client{} userRepository := repository.NewPostgresUser(pool) + farmRepository := repository.NewPostgresFarm(pool) return &api{ logger: logger, httpClient: client, userRepo: userRepository, + farmRepo: farmRepository, } } @@ -69,7 +72,8 @@ func (a *api) Routes() *chi.Mux { router.Group(func(r chi.Router) { api.UseMiddleware(m.AuthMiddleware(api)) - a.registerHelloRoutes(r, api) + a.registerHelloRoutes(r, api) + a.registerFarmRoutes(r, api) }) return router diff --git a/backend/internal/api/farm.go b/backend/internal/api/farm.go new file mode 100644 index 0000000..ecaf840 --- /dev/null +++ b/backend/internal/api/farm.go @@ -0,0 +1,138 @@ +package api + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/forfarm/backend/internal/domain" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) { + tags := []string{"farm"} + prefix := "/farm" + + huma.Register(api, huma.Operation{ + OperationID: "createFarm", + Method: http.MethodPost, + Path: prefix, + Tags: tags, + }, a.createFarmHandler) + + huma.Register(api, huma.Operation{ + OperationID: "getFarmsByOwner", + Method: http.MethodGet, + Path: prefix + "/owner/{owner_id}", + Tags: tags, + }, a.getFarmsByOwnerHandler) + + huma.Register(api, huma.Operation{ + OperationID: "getFarmByID", + Method: http.MethodGet, + Path: prefix + "/{farm_id}", + Tags: tags, + }, a.getFarmByIDHandler) + + huma.Register(api, huma.Operation{ + OperationID: "deleteFarm", + Method: http.MethodDelete, + Path: prefix + "/{farm_id}", + Tags: tags, + }, a.deleteFarmHandler) +} + +type CreateFarmInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + Body struct { + Name string `json:"name"` + Lat []float64 `json:"lat"` + Lon []float64 `json:"lon"` + OwnerID string `json:"owner_id"` + PlantTypes []uuid.UUID `json:"plant_types"` + } +} + +type CreateFarmOutput struct { + Body struct { + UUID string `json:"uuid"` + } +} + +func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*CreateFarmOutput, error) { + farm := &domain.Farm{ + Name: input.Body.Name, + Lat: input.Body.Lat, + Lon: input.Body.Lon, + OwnerID: input.Body.OwnerID, + PlantTypes: input.Body.PlantTypes, + } + + err := a.farmRepo.CreateOrUpdate(ctx, farm) + if err != nil { + return nil, err + } + + return &CreateFarmOutput{Body: struct { + UUID string `json:"uuid"` + }{UUID: farm.UUID}}, nil +} + +type GetFarmsByOwnerInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + OwnerID string `path:"owner_id"` +} + +type GetFarmsByOwnerOutput struct { + Body []domain.Farm +} + +func (a *api) getFarmsByOwnerHandler(ctx context.Context, input *GetFarmsByOwnerInput) (*GetFarmsByOwnerOutput, error) { + farms, err := a.farmRepo.GetByOwnerID(ctx, input.OwnerID) + if err != nil { + return nil, err + } + + return &GetFarmsByOwnerOutput{Body: farms}, nil +} + +type GetFarmByIDInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + FarmID string `path:"farm_id"` +} + +type GetFarmByIDOutput struct { + Body domain.Farm +} + +func (a *api) getFarmByIDHandler(ctx context.Context, input *GetFarmByIDInput) (*GetFarmByIDOutput, error) { + farm, err := a.farmRepo.GetByID(ctx, input.FarmID) + if err != nil { + return nil, err + } + + return &GetFarmByIDOutput{Body: farm}, nil +} + +type DeleteFarmInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + FarmID string `path:"farm_id"` +} + +type DeleteFarmOutput struct { + Body struct { + Message string `json:"message"` + } +} + +func (a *api) deleteFarmHandler(ctx context.Context, input *DeleteFarmInput) (*DeleteFarmOutput, error) { + err := a.farmRepo.Delete(ctx, input.FarmID) + if err != nil { + return nil, err + } + + return &DeleteFarmOutput{Body: struct { + Message string `json:"message"` + }{Message: "Farm deleted successfully"}}, nil +} diff --git a/backend/internal/domain/farm.go b/backend/internal/domain/farm.go index f631334..666c2c1 100644 --- a/backend/internal/domain/farm.go +++ b/backend/internal/domain/farm.go @@ -2,19 +2,20 @@ package domain import ( "context" - "time" - validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/google/uuid" + "time" ) type Farm struct { - UUID string - Name string - Lat float64 - Lon float64 - CreatedAt time.Time - UpdatedAt time.Time - OwnerID string + UUID string + Name string + Lat []float64 + Lon []float64 + CreatedAt time.Time + UpdatedAt time.Time + OwnerID string + PlantTypes []uuid.UUID } func (f *Farm) Validate() error { @@ -28,6 +29,7 @@ func (f *Farm) Validate() error { type FarmRepository interface { GetByID(context.Context, string) (Farm, error) + GetByOwnerID(context.Context, string) ([]Farm, error) CreateOrUpdate(context.Context, *Farm) error - Delete(context.Context, string) error + Delete(context.Context, string) error } diff --git a/backend/internal/domain/plant.go b/backend/internal/domain/plant.go new file mode 100644 index 0000000..d69b6c2 --- /dev/null +++ b/backend/internal/domain/plant.go @@ -0,0 +1,51 @@ +package domain + +import ( + "context" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type Plant struct { + UUID string + Name string + Variety *string + RowSpacing *float64 + OptimalTemp *float64 + PlantingDepth *float64 + AverageHeight *float64 + LightProfileID int + SoilConditionID int + PlantingDetail *string + IsPerennial bool + DaysToEmerge *int + DaysToFlower *int + DaysToMaturity *int + HarvestWindow *int + PHValue *float64 + EstimateLossRate *float64 + EstimateRevenuePerHU *float64 + HarvestUnitID int + WaterNeeds *float64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (p *Plant) Validate() error { + return validation.ValidateStruct(p, + validation.Field(&p.UUID, validation.Required), + validation.Field(&p.Name, validation.Required), + validation.Field(&p.LightProfileID, validation.Required), + validation.Field(&p.SoilConditionID, validation.Required), + validation.Field(&p.HarvestUnitID, validation.Required), + ) +} + +type PlantRepository interface { + GetByUUID(context.Context, string) (Plant, error) + GetAll(context.Context) ([]Plant, error) + Create(context.Context, *Plant) error + Update(context.Context, *Plant) error + Delete(context.Context, string) error +} diff --git a/backend/internal/repository/postgres_farm.go b/backend/internal/repository/postgres_farm.go index e0fa6a4..44ae54f 100644 --- a/backend/internal/repository/postgres_farm.go +++ b/backend/internal/repository/postgres_farm.go @@ -2,11 +2,10 @@ package repository import ( "context" - "strings" - - "github.com/google/uuid" - "github.com/forfarm/backend/internal/domain" + "github.com/google/uuid" + "github.com/lib/pq" + "strings" ) type postgresFarmRepository struct { @@ -27,6 +26,7 @@ func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args . var farms []domain.Farm for rows.Next() { var f domain.Farm + var plantTypes pq.StringArray if err := rows.Scan( &f.UUID, &f.Name, @@ -35,9 +35,19 @@ func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args . &f.CreatedAt, &f.UpdatedAt, &f.OwnerID, + &plantTypes, ); err != nil { return nil, err } + + for _, plantTypeStr := range plantTypes { + plantTypeUUID, err := uuid.Parse(plantTypeStr) + if err != nil { + return nil, err + } + f.PlantTypes = append(f.PlantTypes, plantTypeUUID) + } + farms = append(farms, f) } return farms, nil @@ -45,7 +55,7 @@ func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args . func (p *postgresFarmRepository) GetByID(ctx context.Context, uuid string) (domain.Farm, error) { query := ` - SELECT uuid, name, lat, lon, created_at, updated_at, owner_id + SELECT uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types FROM farms WHERE uuid = $1` @@ -61,7 +71,7 @@ func (p *postgresFarmRepository) GetByID(ctx context.Context, uuid string) (doma func (p *postgresFarmRepository) GetByOwnerID(ctx context.Context, ownerID string) ([]domain.Farm, error) { query := ` - SELECT uuid, name, lat, lon, created_at, updated_at, owner_id + SELECT uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types FROM farms WHERE owner_id = $1` @@ -73,15 +83,21 @@ func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.F f.UUID = uuid.New().String() } + plantTypes := make([]string, len(f.PlantTypes)) + for i, pt := range f.PlantTypes { + plantTypes[i] = pt.String() + } + query := ` - INSERT INTO farms (uuid, name, lat, lon, created_at, updated_at, owner_id) - VALUES ($1, $2, $3, $4, NOW(), NOW(), $5) + INSERT INTO farms (uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types) + VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6) ON CONFLICT (uuid) DO UPDATE SET name = EXCLUDED.name, lat = EXCLUDED.lat, lon = EXCLUDED.lon, updated_at = NOW(), - owner_id = EXCLUDED.owner_id + owner_id = EXCLUDED.owner_id, + plant_types = EXCLUDED.plant_types RETURNING uuid, created_at, updated_at` return p.conn.QueryRow( @@ -92,7 +108,8 @@ func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.F f.Lat, f.Lon, f.OwnerID, - ).Scan(&f.CreatedAt, &f.UpdatedAt) + pq.StringArray(plantTypes), + ).Scan(&f.UUID, &f.CreatedAt, &f.UpdatedAt) } func (p *postgresFarmRepository) Delete(ctx context.Context, uuid string) error { diff --git a/backend/internal/repository/postgres_plant.go b/backend/internal/repository/postgres_plant.go new file mode 100644 index 0000000..797f498 --- /dev/null +++ b/backend/internal/repository/postgres_plant.go @@ -0,0 +1,85 @@ +package repository + +import ( + "context" + "strings" + + "github.com/forfarm/backend/internal/domain" + "github.com/google/uuid" +) + +type postgresPlantRepository struct { + conn Connection +} + +func NewPostgresPlant(conn Connection) domain.PlantRepository { + return &postgresPlantRepository{conn: conn} +} + +func (p *postgresPlantRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Plant, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var plants []domain.Plant + for rows.Next() { + var plant domain.Plant + if err := rows.Scan( + &plant.UUID, &plant.Name, &plant.Variety, + &plant.RowSpacing, &plant.OptimalTemp, &plant.PlantingDepth, + &plant.AverageHeight, &plant.LightProfileID, &plant.SoilConditionID, + &plant.PlantingDetail, &plant.IsPerennial, &plant.DaysToEmerge, + &plant.DaysToFlower, &plant.DaysToMaturity, &plant.HarvestWindow, + &plant.PHValue, &plant.EstimateLossRate, &plant.EstimateRevenuePerHU, + &plant.HarvestUnitID, &plant.WaterNeeds, &plant.CreatedAt, &plant.UpdatedAt, + ); err != nil { + return nil, err + } + plants = append(plants, plant) + } + return plants, nil +} + +func (p *postgresPlantRepository) GetByUUID(ctx context.Context, uuid string) (domain.Plant, error) { + query := `SELECT * FROM plants WHERE uuid = $1` + plants, err := p.fetch(ctx, query, uuid) + 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) +} + +func (p *postgresPlantRepository) Create(ctx context.Context, plant *domain.Plant) error { + if strings.TrimSpace(plant.UUID) == "" { + plant.UUID = uuid.New().String() + } + if err := plant.Validate(); err != nil { + return err + } + query := `INSERT INTO plants (uuid, name, light_profile_id, soil_condition_id, harvest_unit_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) RETURNING created_at, updated_at` + return p.conn.QueryRow(ctx, query, plant.UUID, plant.Name, plant.LightProfileID, plant.SoilConditionID, plant.HarvestUnitID).Scan(&plant.CreatedAt, &plant.UpdatedAt) +} + +func (p *postgresPlantRepository) Update(ctx context.Context, plant *domain.Plant) error { + if err := plant.Validate(); err != nil { + return err + } + query := `UPDATE plants SET name = $2, light_profile_id = $3, soil_condition_id = $4, + harvest_unit_id = $5, updated_at = NOW() WHERE uuid = $1` + _, err := p.conn.Exec(ctx, query, plant.UUID, plant.Name, plant.LightProfileID, plant.SoilConditionID, plant.HarvestUnitID) + return err +} + +func (p *postgresPlantRepository) Delete(ctx context.Context, uuid string) error { + query := `DELETE FROM plants WHERE uuid = $1` + _, err := p.conn.Exec(ctx, query, uuid) + return err +} diff --git a/backend/internal/utilities/jwt.go b/backend/internal/utilities/jwt.go index 30f3e96..5b406b3 100644 --- a/backend/internal/utilities/jwt.go +++ b/backend/internal/utilities/jwt.go @@ -34,10 +34,6 @@ func VerifyJwtToken(tokenString string, customKey ...[]byte) error { secretKey = customKey[0] } - if len(secretKey) == 0 { - return errors.New("no secret key available") - } - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid diff --git a/backend/migrations/00002_create_farm_and_cropland_tables.sql b/backend/migrations/00002_create_farm_and_cropland_tables.sql index 8f2df4b..bd5a114 100644 --- a/backend/migrations/00002_create_farm_and_cropland_tables.sql +++ b/backend/migrations/00002_create_farm_and_cropland_tables.sql @@ -43,8 +43,9 @@ CREATE TABLE plants ( CREATE TABLE farms ( uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, - lat DOUBLE PRECISION NOT NULL, - lon DOUBLE PRECISION NOT NULL, + lat DOUBLE PRECISION[] NOT NULL, + lon DOUBLE PRECISION[] NOT NULL, + plant_types UUID[], created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), owner_id UUID NOT NULL, diff --git a/frontend/app/(sidebar)/farms/[farmId]/add-crop-form.tsx b/frontend/app/(sidebar)/farms/[farmId]/add-crop-form.tsx new file mode 100644 index 0000000..ce33e21 --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/add-crop-form.tsx @@ -0,0 +1,98 @@ +"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, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Crop } from "@/types"; +import { cropFormSchema } from "@/schemas/form.schema"; + +interface AddCropFormProps { + onSubmit: (data: Partial) => Promise; + onCancel: () => void; +} + +export function AddCropForm({ onSubmit, onCancel }: AddCropFormProps) { + const form = useForm>({ + resolver: zodResolver(cropFormSchema), + defaultValues: { + name: "", + plantedDate: "", + status: "planned", + }, + }); + + const handleSubmit = (values: z.infer) => { + onSubmit({ + ...values, + plantedDate: new Date(values.plantedDate), + }); + }; + + return ( +
+ + ( + + Crop Name + + + + + + )} + /> + + ( + + Planted Date + + + + + + )} + /> + + ( + + Status + + + + )} + /> + +
+ + +
+ + + ); +} diff --git a/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx b/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx new file mode 100644 index 0000000..6d9dd72 --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx @@ -0,0 +1,37 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Sprout, Calendar } from "lucide-react"; +import { Crop } from "@/types"; + +interface CropCardProps { + crop: Crop; +} + +export function CropCard({ crop }: CropCardProps) { + const statusColors = { + growing: "text-green-500", + harvested: "text-yellow-500", + planned: "text-blue-500", + }; + + return ( + + +
+
+ +
+ {crop.status} +
+
+ +
+

{crop.name}

+
+ +

Planted: {crop.plantedDate.toLocaleDateString()}

+
+
+
+
+ ); +} diff --git a/frontend/app/(sidebar)/farms/[farmId]/page.tsx b/frontend/app/(sidebar)/farms/[farmId]/page.tsx new file mode 100644 index 0000000..aa500ce --- /dev/null +++ b/frontend/app/(sidebar)/farms/[farmId]/page.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, MapPin, Plus, Sprout } from "lucide-react"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { AddCropForm } from "./add-crop-form"; +import { CropCard } from "./crop-card"; +import { Farm, Crop } from "@/types"; +import React from "react"; + +const crops: Crop[] = [ + { + id: "crop1", + farmId: "1", + name: "Monthong Durian", + plantedDate: new Date("2023-03-15"), + status: "growing", + }, + { + id: "crop2", + farmId: "1", + name: "Chanee Durian", + plantedDate: new Date("2023-02-20"), + status: "planned", + }, + { + id: "crop3", + farmId: "2", + name: "Kradum Durian", + plantedDate: new Date("2022-11-05"), + status: "harvested", + }, +]; + +const farms: Farm[] = [ + { + id: "1", + name: "Green Valley Farm", + location: "Bangkok", + type: "durian", + createdAt: new Date("2023-01-01"), + }, + { + id: "2", + name: "Golden Farm", + location: "Chiang Mai", + type: "mango", + createdAt: new Date("2022-12-01"), + }, +]; + +const getFarmById = (id: string): Farm | undefined => { + return farms.find((farm) => farm.id === id); +}; + +const getCropsByFarmId = (farmId: string): Crop[] => crops.filter((crop) => crop.farmId === farmId); + +export default function FarmDetailPage({ params }: { params: Promise<{ farmId: string }> }) { + const { farmId } = React.use(params); + + const router = useRouter(); + const [farm] = useState(getFarmById(farmId)); + const [crops, setCrops] = useState(getCropsByFarmId(farmId)); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const handleAddCrop = async (data: Partial) => { + const newCrop: Crop = { + id: Math.random().toString(36).substr(2, 9), + farmId: farm!.id, + name: data.name!, + plantedDate: data.plantedDate!, + status: data.status!, + }; + setCrops((prevCrops) => [...prevCrops, newCrop]); + setIsDialogOpen(false); + }; + + return ( +
+ + +
+ + +
+
+ +
+

{farm?.name ?? "Unknown Farm"}

+
+
+ + {farm?.location ?? "Unknown Location"} +
+
+ +
+
+ Farm Type: + {farm?.type ?? "Unknown Type"} +
+
+ Created: + {farm?.createdAt?.toLocaleDateString() ?? "Unknown Date"} +
+
+ Total Crops: + {crops.length} +
+
+
+
+ +
+

Crops

+ +
+ + setIsDialogOpen(true)}> + +
+
+ +
+
+

Add Crop

+

Plant a new crop

+
+
+
+
+ + + Add New Crop + Fill out the form to add a new crop to your farm. + + setIsDialogOpen(false)} /> + +
+ + {crops.map((crop) => ( + + ))} +
+
+
+
+ ); +} diff --git a/frontend/app/(sidebar)/farms/add-farm-form.tsx b/frontend/app/(sidebar)/farms/add-farm-form.tsx new file mode 100644 index 0000000..288197a --- /dev/null +++ b/frontend/app/(sidebar)/farms/add-farm-form.tsx @@ -0,0 +1,93 @@ +"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 type { Farm } from "@/types"; +import { farmFormSchema } from "@/schemas/form.schema"; + +interface AddFarmFormProps { + onSubmit: (data: Partial) => Promise; + onCancel: () => void; +} + +export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) { + const form = useForm>({ + resolver: zodResolver(farmFormSchema), + defaultValues: { + name: "", + location: "", + type: "", + }, + }); + + return ( +
+ + ( + + Farm Name + + + + This is your farm's display name. + + + )} + /> + + ( + + Location + + + + + + )} + /> + + ( + + Farm Type + + + + )} + /> + +
+ + +
+ + + ); +} diff --git a/frontend/app/(sidebar)/farms/farm-card.tsx b/frontend/app/(sidebar)/farms/farm-card.tsx new file mode 100644 index 0000000..210cd1c --- /dev/null +++ b/frontend/app/(sidebar)/farms/farm-card.tsx @@ -0,0 +1,61 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { MapPin, Sprout, Plus } from "lucide-react"; +import type { Farm } from "@/types"; + +export interface FarmCardProps { + variant: "farm" | "add"; + farm?: Farm; + onClick?: () => void; +} + +export function FarmCard({ variant, farm, onClick }: FarmCardProps) { + const cardClasses = + "w-full max-w-[240px] bg-muted/50 hover:bg-muted/80 transition-all cursor-pointer group hover:shadow-lg"; + + if (variant === "add") { + return ( + + +
+
+ +
+
+

Setup

+

Setup new farm

+
+
+
+
+ ); + } + + if (variant === "farm" && farm) { + return ( + + +
+
+ +
+ {farm.type} +
+
+ +
+
+

{farm.name}

+
+ +

{farm.location}

+
+
+
Created {farm.createdAt.toLocaleDateString()}
+
+
+
+ ); + } + + return null; +} diff --git a/frontend/app/(sidebar)/farms/page.tsx b/frontend/app/(sidebar)/farms/page.tsx new file mode 100644 index 0000000..04895ea --- /dev/null +++ b/frontend/app/(sidebar)/farms/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; +import { FarmCard } from "./farm-card"; +import { AddFarmForm } from "./add-farm-form"; +import type { Farm } from "@/types"; + +export default function FarmSetupPage() { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [farms, setFarms] = useState([ + { + id: "1", + name: "Green Valley Farm", + location: "Bangkok", + type: "durian", + createdAt: new Date(), + }, + ]); + + const handleAddFarm = async (data: Partial) => { + const newFarm: Farm = { + id: Math.random().toString(36).substr(2, 9), + name: data.name!, + location: data.location!, + type: data.type!, + createdAt: new Date(), + }; + setFarms([...farms, newFarm]); + setIsDialogOpen(false); + }; + + const filteredFarms = farms.filter( + (farm) => + farm.name.toLowerCase().includes(searchQuery.toLowerCase()) || + farm.location.toLowerCase().includes(searchQuery.toLowerCase()) || + farm.type.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( +
+
+

Farms

+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + +
+ + setIsDialogOpen(true)} /> + + + Setup New Farm + Fill out the form to configure your new farm. + + setIsDialogOpen(false)} /> + + + + {filteredFarms.map((farm) => ( + + ))} +
+
+ ); +} diff --git a/frontend/app/setup/layout.tsx b/frontend/app/(sidebar)/layout.tsx similarity index 100% rename from frontend/app/setup/layout.tsx rename to frontend/app/(sidebar)/layout.tsx diff --git a/frontend/app/setup/google-map-with-drawing.tsx b/frontend/app/(sidebar)/setup/google-map-with-drawing.tsx similarity index 76% rename from frontend/app/setup/google-map-with-drawing.tsx rename to frontend/app/(sidebar)/setup/google-map-with-drawing.tsx index 46ddbd5..5362123 100644 --- a/frontend/app/setup/google-map-with-drawing.tsx +++ b/frontend/app/(sidebar)/setup/google-map-with-drawing.tsx @@ -14,24 +14,13 @@ const GoogleMapWithDrawing = () => { const [map, setMap] = useState(null); // Handles drawing complete - const onDrawingComplete = useCallback( - (overlay: google.maps.drawing.OverlayCompleteEvent) => { - console.log("Drawing complete:", overlay); - }, - [] - ); + const onDrawingComplete = useCallback((overlay: google.maps.drawing.OverlayCompleteEvent) => { + console.log("Drawing complete:", overlay); + }, []); return ( - - setMap(map)} - > + + setMap(map)}> {map && ( +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/frontend/schema/authSchema.ts b/frontend/schemas/auth.schema.ts similarity index 100% rename from frontend/schema/authSchema.ts rename to frontend/schemas/auth.schema.ts diff --git a/frontend/schemas/form.schema.ts b/frontend/schemas/form.schema.ts new file mode 100644 index 0000000..387bd96 --- /dev/null +++ b/frontend/schemas/form.schema.ts @@ -0,0 +1,15 @@ +import * as z from "zod"; + +export const farmFormSchema = z.object({ + name: z.string().min(2, "Farm name must be at least 2 characters"), + location: z.string().min(2, "Location must be at least 2 characters"), + type: z.string().min(1, "Please select a farm type"), +}); + +export const cropFormSchema = z.object({ + name: z.string().min(2, "Crop name must be at least 2 characters"), + plantedDate: z.string().refine((val) => !Number.isNaN(Date.parse(val)), { + message: "Please enter a valid date", + }), + status: z.enum(["growing", "harvested", "planned"]), +}); diff --git a/frontend/types.ts b/frontend/types.ts new file mode 100644 index 0000000..5ae3150 --- /dev/null +++ b/frontend/types.ts @@ -0,0 +1,15 @@ +export interface Crop { + id: string; + farmId: string; + name: string; + plantedDate: Date; + status: "growing" | "harvested" | "planned"; +} + +export interface Farm { + id: string; + name: string; + location: string; + type: string; + createdAt: Date; +}