diff --git a/backend/internal/domain/cropland.go b/backend/internal/domain/cropland.go index 17a7192..984e6e4 100644 --- a/backend/internal/domain/cropland.go +++ b/backend/internal/domain/cropland.go @@ -32,5 +32,5 @@ func (c *Cropland) Validate() error { type CroplandRepository interface { GetByID(context.Context, string) (Cropland, error) CreateOrUpdate(context.Context, *Cropland) error - // Delete(context.Context, string) error + Delete(context.Context, string) error } diff --git a/backend/internal/domain/farm.go b/backend/internal/domain/farm.go index 5bc1154..1dd2aaf 100644 --- a/backend/internal/domain/farm.go +++ b/backend/internal/domain/farm.go @@ -29,5 +29,5 @@ func (f *Farm) Validate() error { type FarmRepository interface { GetByID(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/repository/postgres_cropland.go b/backend/internal/repository/postgres_cropland.go new file mode 100644 index 0000000..7cc48f8 --- /dev/null +++ b/backend/internal/repository/postgres_cropland.go @@ -0,0 +1,111 @@ +package repository + +import ( + "context" + "strings" + + "github.com/google/uuid" + + "github.com/forfarm/backend/internal/domain" +) + +type postgresCroplandRepository struct { + conn Connection +} + +func NewPostgresCropland(conn Connection) domain.CroplandRepository { + return &postgresCroplandRepository{conn: conn} +} + +func (p *postgresCroplandRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Cropland, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var croplands []domain.Cropland + for rows.Next() { + var c domain.Cropland + if err := rows.Scan( + &c.UUID, + &c.Name, + &c.Status, + &c.Priority, + &c.LandSize, + &c.GrowthStage, + &c.PlantID, + &c.FarmID, + &c.CreatedAt, + &c.UpdatedAt, + ); err != nil { + return nil, err + } + croplands = append(croplands, c) + } + return croplands, nil +} + +func (p *postgresCroplandRepository) GetByID(ctx context.Context, uuid string) (domain.Cropland, error) { + query := ` + SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at + FROM croplands + WHERE uuid = $1` + + croplands, err := p.fetch(ctx, query, uuid) + if err != nil { + return domain.Cropland{}, err + } + if len(croplands) == 0 { + return domain.Cropland{}, domain.ErrNotFound + } + return croplands[0], nil +} + +func (p *postgresCroplandRepository) GetByFarmID(ctx context.Context, farmID string) ([]domain.Cropland, error) { + query := ` + SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at + FROM croplands + WHERE farm_id = $1` + + return p.fetch(ctx, query, farmID) +} + +func (p *postgresCroplandRepository) CreateOrUpdate(ctx context.Context, c *domain.Cropland) error { + if strings.TrimSpace(c.UUID) == "" { + c.UUID = uuid.New().String() + } + + query := ` + INSERT INTO croplands (uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) + ON CONFLICT (uuid) DO UPDATE + SET name = EXCLUDED.name, + status = EXCLUDED.status, + priority = EXCLUDED.priority, + land_size = EXCLUDED.land_size, + growth_stage = EXCLUDED.growth_stage, + plant_id = EXCLUDED.plant_id, + farm_id = EXCLUDED.farm_id, + updated_at = NOW() + RETURNING uuid, created_at, updated_at` + + return p.conn.QueryRow( + ctx, + query, + c.UUID, + c.Name, + c.Status, + c.Priority, + c.LandSize, + c.GrowthStage, + c.PlantID, + c.FarmID, + ).Scan(&c.CreatedAt, &c.UpdatedAt) +} + +func (p *postgresCroplandRepository) Delete(ctx context.Context, uuid string) error { + query := `DELETE FROM croplands WHERE uuid = $1` + _, err := p.conn.Exec(ctx, query, uuid) + return err +} diff --git a/backend/internal/repository/postgres_farm.go b/backend/internal/repository/postgres_farm.go new file mode 100644 index 0000000..e0fa6a4 --- /dev/null +++ b/backend/internal/repository/postgres_farm.go @@ -0,0 +1,102 @@ +package repository + +import ( + "context" + "strings" + + "github.com/google/uuid" + + "github.com/forfarm/backend/internal/domain" +) + +type postgresFarmRepository struct { + conn Connection +} + +func NewPostgresFarm(conn Connection) domain.FarmRepository { + return &postgresFarmRepository{conn: conn} +} + +func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Farm, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var farms []domain.Farm + for rows.Next() { + var f domain.Farm + if err := rows.Scan( + &f.UUID, + &f.Name, + &f.Lat, + &f.Lon, + &f.CreatedAt, + &f.UpdatedAt, + &f.OwnerID, + ); err != nil { + return nil, err + } + farms = append(farms, f) + } + return farms, nil +} + +func (p *postgresFarmRepository) GetByID(ctx context.Context, uuid string) (domain.Farm, error) { + query := ` + SELECT uuid, name, lat, lon, created_at, updated_at, owner_id + FROM farms + WHERE uuid = $1` + + farms, err := p.fetch(ctx, query, uuid) + if err != nil { + return domain.Farm{}, err + } + if len(farms) == 0 { + return domain.Farm{}, domain.ErrNotFound + } + return farms[0], nil +} + +func (p *postgresFarmRepository) GetByOwnerID(ctx context.Context, ownerID string) ([]domain.Farm, error) { + query := ` + SELECT uuid, name, lat, lon, created_at, updated_at, owner_id + FROM farms + WHERE owner_id = $1` + + return p.fetch(ctx, query, ownerID) +} + +func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.Farm) error { + if strings.TrimSpace(f.UUID) == "" { + f.UUID = uuid.New().String() + } + + query := ` + INSERT INTO farms (uuid, name, lat, lon, created_at, updated_at, owner_id) + VALUES ($1, $2, $3, $4, NOW(), NOW(), $5) + ON CONFLICT (uuid) DO UPDATE + SET name = EXCLUDED.name, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + updated_at = NOW(), + owner_id = EXCLUDED.owner_id + RETURNING uuid, created_at, updated_at` + + return p.conn.QueryRow( + ctx, + query, + f.UUID, + f.Name, + f.Lat, + f.Lon, + f.OwnerID, + ).Scan(&f.CreatedAt, &f.UpdatedAt) +} + +func (p *postgresFarmRepository) Delete(ctx context.Context, uuid string) error { + query := `DELETE FROM farms WHERE uuid = $1` + _, err := p.conn.Exec(ctx, query, uuid) + return err +} diff --git a/frontend/app/setup/google-map-with-drawing.tsx b/frontend/app/setup/google-map-with-drawing.tsx new file mode 100644 index 0000000..46ddbd5 --- /dev/null +++ b/frontend/app/setup/google-map-with-drawing.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { GoogleMap, LoadScript, DrawingManager } from "@react-google-maps/api"; +import { useState, useCallback } from "react"; + +const containerStyle = { + width: "100%", + height: "500px", +}; + +const center = { lat: 13.7563, lng: 100.5018 }; // Example: Bangkok, Thailand + +const GoogleMapWithDrawing = () => { + const [map, setMap] = useState(null); + + // Handles drawing complete + const onDrawingComplete = useCallback( + (overlay: google.maps.drawing.OverlayCompleteEvent) => { + console.log("Drawing complete:", overlay); + }, + [] + ); + + return ( + + setMap(map)} + > + {map && ( + + )} + + + ); +}; + +export default GoogleMapWithDrawing; diff --git a/frontend/app/setup/page.tsx b/frontend/app/setup/page.tsx index d7afe64..223ec01 100644 --- a/frontend/app/setup/page.tsx +++ b/frontend/app/setup/page.tsx @@ -1,6 +1,7 @@ import PlantingDetailsForm from "./planting-detail-form"; import HarvestDetailsForm from "./harvest-detail-form"; import { Separator } from "@/components/ui/separator"; +import GoogleMapWithDrawing from "./google-map-with-drawing"; export default function SetupPage() { return ( @@ -19,6 +20,15 @@ export default function SetupPage() {
+
+
+

Map

+
+ +
+ +
+
); } diff --git a/frontend/package.json b/frontend/package.json index 3706310..939ee9c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", + "@react-google-maps/api": "^2.20.6", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.66.0", "class-variance-authority": "^0.7.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9f8f5e6..b2bd51c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -38,6 +38,9 @@ dependencies: '@radix-ui/react-tooltip': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@19.0.3)(@types/react@19.0.8)(react-dom@19.0.0)(react@19.0.0) + '@react-google-maps/api': + specifier: ^2.20.6 + version: 2.20.6(react-dom@19.0.0)(react@19.0.0) '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.16(tailwindcss@3.4.17) @@ -224,6 +227,17 @@ packages: resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} dev: false + /@googlemaps/js-api-loader@1.16.8: + resolution: {integrity: sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==} + dev: false + + /@googlemaps/markerclusterer@2.5.3: + resolution: {integrity: sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==} + dependencies: + fast-deep-equal: 3.1.3 + supercluster: 8.0.1 + dev: false + /@hookform/resolvers@4.0.0(react-hook-form@7.54.2): resolution: {integrity: sha512-93ZueVlTaeMF0pRbrLbcnzrxeb2mGA/xyO3RgfrsKs5OCtcfjoWcdjBJm+N7096Jfg/JYlGPjuyQCgqVEodSTg==} peerDependencies: @@ -1285,6 +1299,30 @@ packages: resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} dev: false + /@react-google-maps/api@2.20.6(react-dom@19.0.0)(react@19.0.0): + resolution: {integrity: sha512-frxkSHWbd36ayyxrEVopSCDSgJUT1tVKXvQld2IyzU3UnDuqqNA3AZE4/fCdqQb2/zBQx3nrWnZB1wBXDcrjcw==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 + react-dom: ^16.8 || ^17 || ^18 || ^19 + dependencies: + '@googlemaps/js-api-loader': 1.16.8 + '@googlemaps/markerclusterer': 2.5.3 + '@react-google-maps/infobox': 2.20.0 + '@react-google-maps/marker-clusterer': 2.20.0 + '@types/google.maps': 3.58.1 + invariant: 2.2.4 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + dev: false + + /@react-google-maps/infobox@2.20.0: + resolution: {integrity: sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==} + dev: false + + /@react-google-maps/marker-clusterer@2.20.0: + resolution: {integrity: sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==} + dev: false + /@rtsao/scc@1.1.0: resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} dev: true @@ -1332,6 +1370,10 @@ packages: resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} dev: true + /@types/google.maps@3.58.1: + resolution: {integrity: sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==} + dev: false + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -2363,7 +2405,6 @@ packages: /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true /fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} @@ -2633,6 +2674,12 @@ packages: side-channel: 1.1.0 dev: true + /invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + dependencies: + loose-envify: 1.4.0 + dev: false + /is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2860,7 +2907,6 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: true /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} @@ -2898,6 +2944,10 @@ packages: object.values: 1.2.1 dev: true + /kdbush@4.0.2: + resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} + dev: false + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -2953,7 +3003,6 @@ packages: hasBin: true dependencies: js-tokens: 4.0.0 - dev: true /lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3799,6 +3848,12 @@ packages: pirates: 4.0.6 ts-interface-checker: 0.1.13 + /supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + dependencies: + kdbush: 4.0.2 + dev: false + /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'}