diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 98eca6c..2ee47ce 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -30,6 +30,7 @@ type api struct { cropRepo domain.CroplandRepository farmRepo domain.FarmRepository plantRepo domain.PlantRepository + inventoryRepo domain.InventoryRepository } func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventPublisher domain.EventPublisher) *api { @@ -40,6 +41,7 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventP croplandRepository := repository.NewPostgresCropland(pool) farmRepository := repository.NewPostgresFarm(pool) plantRepository := repository.NewPostgresPlant(pool) + inventoryRepository := repository.NewPostgresInventory(pool) farmRepository.SetEventPublisher(eventPublisher) @@ -52,6 +54,7 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventP cropRepo: croplandRepository, farmRepo: farmRepository, plantRepo: plantRepository, + inventoryRepo: inventoryRepository, } } @@ -100,6 +103,7 @@ func (a *api) Routes() *chi.Mux { a.registerHelloRoutes(r, api) a.registerFarmRoutes(r, api) a.registerUserRoutes(r, api) + a.registerInventoryRoutes(r, api) }) return router diff --git a/backend/internal/api/inventory.go b/backend/internal/api/inventory.go new file mode 100644 index 0000000..206f6b3 --- /dev/null +++ b/backend/internal/api/inventory.go @@ -0,0 +1,288 @@ +package api + +import ( + "context" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/forfarm/backend/internal/domain" + "github.com/go-chi/chi/v5" +) + +func (a *api) registerInventoryRoutes(_ chi.Router, api huma.API) { + tags := []string{"inventory"} + prefix := "/inventory" + + huma.Register(api, huma.Operation{ + OperationID: "createInventoryItem", + Method: http.MethodPost, + Path: prefix, + Tags: tags, + }, a.createInventoryItemHandler) + + huma.Register(api, huma.Operation{ + OperationID: "getInventoryItems", + Method: http.MethodGet, + Path: prefix, + Tags: tags, + }, a.getInventoryItemsHandler) + + huma.Register(api, huma.Operation{ + OperationID: "getInventoryItem", + Method: http.MethodGet, + Path: prefix + "/{id}", + Tags: tags, + }, a.getInventoryItemHandler) + + huma.Register(api, huma.Operation{ + OperationID: "updateInventoryItem", + Method: http.MethodPut, + Path: prefix + "/{id}", + Tags: tags, + }, a.updateInventoryItemHandler) + + huma.Register(api, huma.Operation{ + OperationID: "deleteInventoryItem", + Method: http.MethodDelete, + Path: prefix + "/{id}", + Tags: tags, + }, a.deleteInventoryItemHandler) +} + +type CreateInventoryItemInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + Body struct { + Name string `json:"name" required:"true"` + Category string `json:"category" required:"true"` + Type string `json:"type" required:"true"` + Quantity float64 `json:"quantity" required:"true"` + Unit string `json:"unit" required:"true"` + DateAdded time.Time `json:"date_added" required:"true"` + Status string `json:"status" required:"true" enum:"In Stock,Low Stock,Out of Stock"` + } +} + +type CreateInventoryItemOutput struct { + Body struct { + ID string `json:"id"` + } +} + +func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInventoryItemInput) (*CreateInventoryItemOutput, error) { + item := &domain.InventoryItem{ + Name: input.Body.Name, + Category: input.Body.Category, + Type: input.Body.Type, + Quantity: input.Body.Quantity, + Unit: input.Body.Unit, + DateAdded: input.Body.DateAdded, + Status: domain.InventoryStatus(input.Body.Status), + } + + if err := item.Validate(); err != nil { + return nil, huma.Error422UnprocessableEntity(err.Error()) + } + + err := a.inventoryRepo.CreateOrUpdate(ctx, item) + if err != nil { + return nil, err + } + + return &CreateInventoryItemOutput{Body: struct { + ID string `json:"id"` + }{ID: item.ID}}, nil +} + +type GetInventoryItemsInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + Category string `query:"category"` + Type string `query:"type"` + Status string `query:"status" enum:"In Stock,Low Stock,Out of Stock"` + StartDate time.Time `query:"start_date" format:"date-time"` + EndDate time.Time `query:"end_date" format:"date-time"` + SearchQuery string `query:"search"` + SortBy string `query:"sort_by" enum:"name,category,type,quantity,date_added,status,created_at"` + SortOrder string `query:"sort_order" enum:"asc,desc" default:"desc"` +} + +type InventoryItemResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Type string `json:"type"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + DateAdded time.Time `json:"date_added"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +type GetInventoryItemsOutput struct { + Body []InventoryItemResponse +} + +func (a *api) getInventoryItemsHandler(ctx context.Context, input *GetInventoryItemsInput) (*GetInventoryItemsOutput, error) { + filter := domain.InventoryFilter{ + Category: input.Category, + Type: input.Type, + Status: domain.InventoryStatus(input.Status), + StartDate: input.StartDate, + EndDate: input.EndDate, + SearchQuery: input.SearchQuery, + } + + sort := domain.InventorySort{ + Field: input.SortBy, + Direction: input.SortOrder, + } + + items, err := a.inventoryRepo.GetWithFilter(ctx, filter, sort) + if err != nil { + return nil, err + } + + response := make([]InventoryItemResponse, len(items)) + for i, item := range items { + response[i] = InventoryItemResponse{ + ID: item.ID, + Name: item.Name, + Category: item.Category, + Type: item.Type, + Quantity: item.Quantity, + Unit: item.Unit, + DateAdded: item.DateAdded, + Status: string(item.Status), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } + } + + return &GetInventoryItemsOutput{Body: response}, nil +} + +type GetInventoryItemInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + ID string `path:"id"` +} + +type GetInventoryItemOutput struct { + Body InventoryItemResponse +} + +func (a *api) getInventoryItemHandler(ctx context.Context, input *GetInventoryItemInput) (*GetInventoryItemOutput, error) { + item, err := a.inventoryRepo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + + return &GetInventoryItemOutput{Body: InventoryItemResponse{ + ID: item.ID, + Name: item.Name, + Category: item.Category, + Type: item.Type, + Quantity: item.Quantity, + Unit: item.Unit, + DateAdded: item.DateAdded, + Status: string(item.Status), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }}, nil +} + +type UpdateInventoryItemInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + ID string `path:"id"` + Body struct { + Name string `json:"name"` + Category string `json:"category"` + Type string `json:"type"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + DateAdded time.Time `json:"date_added"` + Status string `json:"status" enum:"In Stock,Low Stock,Out of Stock"` + } +} + +type UpdateInventoryItemOutput struct { + Body InventoryItemResponse +} + +func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInventoryItemInput) (*UpdateInventoryItemOutput, error) { + item, err := a.inventoryRepo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + + if input.Body.Name != "" { + item.Name = input.Body.Name + } + if input.Body.Category != "" { + item.Category = input.Body.Category + } + if input.Body.Type != "" { + item.Type = input.Body.Type + } + if input.Body.Quantity != 0 { + item.Quantity = input.Body.Quantity + } + if input.Body.Unit != "" { + item.Unit = input.Body.Unit + } + if !input.Body.DateAdded.IsZero() { + item.DateAdded = input.Body.DateAdded + } + if input.Body.Status != "" { + item.Status = domain.InventoryStatus(input.Body.Status) + } + + if err := item.Validate(); err != nil { + return nil, huma.Error422UnprocessableEntity(err.Error()) + } + + err = a.inventoryRepo.CreateOrUpdate(ctx, &item) + if err != nil { + return nil, err + } + + updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + + return &UpdateInventoryItemOutput{Body: InventoryItemResponse{ + ID: updatedItem.ID, + Name: updatedItem.Name, + Category: updatedItem.Category, + Type: updatedItem.Type, + Quantity: updatedItem.Quantity, + Unit: updatedItem.Unit, + DateAdded: updatedItem.DateAdded, + Status: string(updatedItem.Status), + CreatedAt: updatedItem.CreatedAt, + UpdatedAt: updatedItem.UpdatedAt, + }}, nil +} + +type DeleteInventoryItemInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + ID string `path:"id"` +} + +type DeleteInventoryItemOutput struct { + Body struct { + Message string `json:"message"` + } +} + +func (a *api) deleteInventoryItemHandler(ctx context.Context, input *DeleteInventoryItemInput) (*DeleteInventoryItemOutput, error) { + err := a.inventoryRepo.Delete(ctx, input.ID) + if err != nil { + return nil, err + } + + return &DeleteInventoryItemOutput{Body: struct { + Message string `json:"message"` + }{Message: "Inventory item deleted successfully"}}, nil +} diff --git a/backend/internal/domain/inventory.go b/backend/internal/domain/inventory.go new file mode 100644 index 0000000..ecd6169 --- /dev/null +++ b/backend/internal/domain/inventory.go @@ -0,0 +1,61 @@ +package domain + +import ( + "context" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type InventoryStatus string + +const ( + StatusInStock InventoryStatus = "In Stock" + StatusLowStock InventoryStatus = "Low Stock" + StatusOutOfStock InventoryStatus = "Out of Stock" +) + +type InventoryItem struct { + ID string + Name string + Category string + Type string + Quantity float64 + Unit string + DateAdded time.Time + Status InventoryStatus + CreatedAt time.Time + UpdatedAt time.Time +} + +type InventoryFilter struct { + Category string + Type string + Status InventoryStatus + StartDate time.Time + EndDate time.Time + SearchQuery string +} + +type InventorySort struct { + Field string + Direction string +} + +func (i *InventoryItem) Validate() error { + return validation.ValidateStruct(i, + validation.Field(&i.Name, validation.Required), + validation.Field(&i.Category, validation.Required), + validation.Field(&i.Type, validation.Required), + validation.Field(&i.Quantity, validation.Required, validation.Min(0.0)), + validation.Field(&i.Unit, validation.Required), + validation.Field(&i.Status, validation.Required, validation.In(StatusInStock, StatusLowStock, StatusOutOfStock)), + ) +} + +type InventoryRepository interface { + GetByID(ctx context.Context, id string) (InventoryItem, error) + GetWithFilter(ctx context.Context, filter InventoryFilter, sort InventorySort) ([]InventoryItem, error) + CreateOrUpdate(ctx context.Context, item *InventoryItem) error + Delete(ctx context.Context, id string) error +} diff --git a/backend/internal/repository/postgres_inventory.go b/backend/internal/repository/postgres_inventory.go new file mode 100644 index 0000000..1a05655 --- /dev/null +++ b/backend/internal/repository/postgres_inventory.go @@ -0,0 +1,196 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/forfarm/backend/internal/domain" +) + +type postgresInventoryRepository struct { + conn Connection +} + +func NewPostgresInventory(conn Connection) domain.InventoryRepository { + return &postgresInventoryRepository{conn: conn} +} + +func (p *postgresInventoryRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.InventoryItem, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []domain.InventoryItem + for rows.Next() { + var i domain.InventoryItem + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Category, + &i.Type, + &i.Quantity, + &i.Unit, + &i.DateAdded, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + return items, nil +} + +func (p *postgresInventoryRepository) GetByID(ctx context.Context, id string) (domain.InventoryItem, error) { + query := ` + SELECT id, name, category, type, quantity, unit, date_added, status, created_at, updated_at + FROM inventory_items + WHERE id = $1` + + items, err := p.fetch(ctx, query, id) + if err != nil { + return domain.InventoryItem{}, err + } + if len(items) == 0 { + return domain.InventoryItem{}, domain.ErrNotFound + } + return items[0], nil +} + +func (p *postgresInventoryRepository) GetWithFilter(ctx context.Context, filter domain.InventoryFilter, sort domain.InventorySort) ([]domain.InventoryItem, error) { + var query strings.Builder + args := []interface{}{} + argPos := 1 + + query.WriteString(` + SELECT id, name, category, type, quantity, unit, date_added, status, created_at, updated_at + FROM inventory_items + WHERE 1=1`) + + if filter.Category != "" { + query.WriteString(fmt.Sprintf(" AND category = $%d", argPos)) + args = append(args, filter.Category) + argPos++ + } + + if filter.Type != "" { + query.WriteString(fmt.Sprintf(" AND type = $%d", argPos)) + args = append(args, filter.Type) + argPos++ + } + + if filter.Status != "" { + query.WriteString(fmt.Sprintf(" AND status = $%d", argPos)) + args = append(args, filter.Status) + argPos++ + } + + if !filter.StartDate.IsZero() { + query.WriteString(fmt.Sprintf(" AND date_added >= $%d", argPos)) + args = append(args, filter.StartDate) + argPos++ + } + + if !filter.EndDate.IsZero() { + query.WriteString(fmt.Sprintf(" AND date_added <= $%d", argPos)) + args = append(args, filter.EndDate) + argPos++ + } + + if filter.SearchQuery != "" { + query.WriteString(fmt.Sprintf(" AND name ILIKE $%d", argPos)) + args = append(args, "%"+filter.SearchQuery+"%") + argPos++ + } + + if sort.Field == "" { + sort.Field = "date_added" + sort.Direction = "desc" + } + + validSortFields := map[string]bool{ + "name": true, + "category": true, + "type": true, + "quantity": true, + "date_added": true, + "status": true, + "created_at": true, + } + + if validSortFields[sort.Field] { + query.WriteString(fmt.Sprintf(" ORDER BY %s", sort.Field)) + if strings.ToLower(sort.Direction) == "desc" { + query.WriteString(" DESC") + } else { + query.WriteString(" ASC") + } + } + + return p.fetch(ctx, query.String(), args...) +} + +func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *domain.InventoryItem) error { + now := time.Now() + item.UpdatedAt = now + + if item.ID == "" { + item.CreatedAt = now + query := ` + INSERT INTO inventory_items + (id, name, category, type, quantity, unit, date_added, status, created_at, updated_at) + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id` + return p.conn.QueryRow( + ctx, + query, + item.Name, + item.Category, + item.Type, + item.Quantity, + item.Unit, + item.DateAdded, + item.Status, + item.CreatedAt, + item.UpdatedAt, + ).Scan(&item.ID) + } + + query := ` + UPDATE inventory_items + SET name = $1, + category = $2, + type = $3, + quantity = $4, + unit = $5, + date_added = $6, + status = $7, + updated_at = $8 + WHERE id = $9 + RETURNING id` + + return p.conn.QueryRow( + ctx, + query, + item.Name, + item.Category, + item.Type, + item.Quantity, + item.Unit, + item.DateAdded, + item.Status, + item.UpdatedAt, + item.ID, + ).Scan(&item.ID) +} + +func (p *postgresInventoryRepository) Delete(ctx context.Context, id string) error { + query := `DELETE FROM inventory_items WHERE id = $1` + _, err := p.conn.Exec(ctx, query, id) + return err +} diff --git a/backend/migrations/00004_create_inventory_items_table.sql b/backend/migrations/00004_create_inventory_items_table.sql new file mode 100644 index 0000000..eaf6945 --- /dev/null +++ b/backend/migrations/00004_create_inventory_items_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE inventory_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + category TEXT NOT NULL, + type TEXT NOT NULL, + quantity DOUBLE PRECISION NOT NULL, + unit TEXT NOT NULL, + date_added TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_inventory_items_category ON inventory_items(category); +CREATE INDEX idx_inventory_items_status ON inventory_items(status); diff --git a/frontend/api/inventory.ts b/frontend/api/inventory.ts index 7d8982d..69fd6b3 100644 --- a/frontend/api/inventory.ts +++ b/frontend/api/inventory.ts @@ -79,7 +79,10 @@ export async function createInventoryItem( // Simulate network delay await new Promise((resolve) => setTimeout(resolve, 500)); try { - const response = await axiosInstance.post("/api/inventory", item); + const response = await axiosInstance.post( + "/api/inventory", + item + ); return response.data; } catch (error) { // Simulate successful creation if API endpoint is not available diff --git a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx index 0b767fd..4c444ab 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx @@ -18,7 +18,13 @@ import { CloudRain, Wind, } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; @@ -26,7 +32,11 @@ import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ChatbotDialog } from "./chatbot-dialog"; import { AnalyticsDialog } from "./analytics-dialog"; -import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import type { Crop, CropAnalytics } from "@/types"; import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; @@ -37,7 +47,11 @@ interface CropDetailPageParams { cropId: string; } -export default function CropDetailPage({ params }: { params: Promise }) { +export default function CropDetailPage({ + params, +}: { + params: Promise; +}) { const router = useRouter(); const [crop, setCrop] = useState(null); const [analytics, setAnalytics] = useState(null); @@ -57,7 +71,9 @@ export default function CropDetailPage({ params }: { params: PromiseLoading... +
+ Loading... +
); } @@ -87,7 +103,8 @@ export default function CropDetailPage({ params }: { params: Promise console.log("Details clicked"), - color: "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300", + color: + "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300", }, { title: "Settings", @@ -107,7 +124,8 @@ export default function CropDetailPage({ params }: { params: Promise router.back()}> + onClick={() => router.back()} + > Back to Farm @@ -126,7 +144,9 @@ export default function CropDetailPage({ params }: { params: Promise

Growth Timeline

-

Planted on {crop.plantedDate.toLocaleDateString()}

+

+ Planted on {crop.plantedDate.toLocaleDateString()} +

@@ -150,19 +170,28 @@ export default function CropDetailPage({ params }: { params: Promise
- + Health Score: {crop.healthScore}% - + Growing
{crop.expectedHarvest ? (

- Expected harvest: {crop.expectedHarvest.toLocaleDateString()} + Expected harvest:{" "} + {crop.expectedHarvest.toLocaleDateString()}

) : ( -

Expected harvest date not available

+

+ Expected harvest date not available +

)}
@@ -180,13 +209,18 @@ export default function CropDetailPage({ params }: { params: Promise -
+ onClick={action.onClick} + > +
{action.title}
-

{action.description}

+

+ {action.description} +

))} @@ -196,7 +230,9 @@ export default function CropDetailPage({ params }: { params: Promise Environmental Conditions - Real-time monitoring of growing conditions + + Real-time monitoring of growing conditions +
@@ -247,15 +283,22 @@ export default function CropDetailPage({ params }: { params: Promise ( + className="border-none shadow-none bg-gradient-to-br from-white to-gray-50/50 dark:from-slate-800 dark:to-slate-700/50" + >
- +
-

{metric.label}

-

{metric.value}

+

+ {metric.label} +

+

+ {metric.value} +

@@ -269,9 +312,14 @@ export default function CropDetailPage({ params }: { params: Promise
Growth Progress - {analytics.growthProgress}% + + {analytics.growthProgress}% +
- +
{/* Next Action Card */} @@ -282,10 +330,15 @@ export default function CropDetailPage({ params }: { params: Promise
-

Next Action Required

-

{analytics.nextAction}

+

+ Next Action Required +

+

+ {analytics.nextAction} +

- Due by {analytics.nextActionDue.toLocaleDateString()} + Due by{" "} + {analytics.nextActionDue.toLocaleDateString()}

@@ -337,9 +390,14 @@ export default function CropDetailPage({ params }: { params: Promise
{nutrient.name} - {nutrient.value}% + + {nutrient.value}% +
- + ))} @@ -372,10 +430,14 @@ export default function CropDetailPage({ params }: { params: Promise -

2 hours ago

+

+ 2 hours ago +

- {i < 4 && } + {i < 4 && ( + + )} ))} @@ -385,8 +447,17 @@ export default function CropDetailPage({ params }: { params: Promise {/* Dialogs */} - - + + ); @@ -399,9 +470,15 @@ function Activity({ icon }: { icon: number }) { const icons = [ , , - , + , , - , + , ]; return icons[icon]; } diff --git a/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx b/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx new file mode 100644 index 0000000..e53e37d --- /dev/null +++ b/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; +import { format } from "date-fns"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +// import { deleteInventoryItem } from "@/api/inventory"; +// import type { DeleteInventoryItemInput } from "@/types"; + +export function DeleteInventoryItem() { + const [date, setDate] = useState(); + const [open, setOpen] = useState(false); + const [itemName, setItemName] = useState(""); + const [itemType, setItemType] = useState(""); + const [itemCategory, setItemCategory] = useState(""); + const [itemQuantity, setItemQuantity] = useState(0); + const [itemUnit, setItemUnit] = useState(""); + + // const queryClient = useQueryClient(); + + const handleDelete = () => { + // handle delete item + }; + + return ( + + ); +} diff --git a/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx b/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx new file mode 100644 index 0000000..0affb70 --- /dev/null +++ b/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; +import { format } from "date-fns"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +// import { updateInventoryItem } from "@/api/inventory"; +// import type { UpdateInventoryItemInput } from "@/types"; + +export function EditInventoryItem() { + const [date, setDate] = useState(); + const [open, setOpen] = useState(false); + const [itemName, setItemName] = useState(""); + const [itemType, setItemType] = useState(""); + const [itemCategory, setItemCategory] = useState(""); + const [itemQuantity, setItemQuantity] = useState(0); + const [itemUnit, setItemUnit] = useState(""); + + // const queryClient = useQueryClient(); + + // const mutation = useMutation({ + // mutationFn: (item: UpdateInventoryItemInput) => UpdateInventoryItem(item), + // onSuccess: () => { + // // Invalidate queries to refresh inventory data. + // queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); + // // Reset form fields and close dialog. + // setItemName(""); + // setItemType(""); + // setItemCategory(""); + // setItemQuantity(0); + // setItemUnit(""); + // setDate(undefined); + // setOpen(false); + // }, + // }); + + const handleEdit = () => { + // // Basic validation (you can extend this as needed) + // if (!itemName || !itemType || !itemCategory || !itemUnit) return; + // mutation.mutate({ + // name: itemName, + // type: itemType, + // category: itemCategory, + // quantity: itemQuantity, + // unit: itemUnit, + // }); + }; + + return ( + + + + + + + Edit Inventory Item + + Edit a plantation or fertilizer item in your inventory. + + +
+
+ + setItemName(e.target.value)} + /> +
+
+ + +
+
+ + setItemCategory(e.target.value)} + /> +
+
+ + setItemQuantity(Number(e.target.value))} + /> +
+
+ + setItemUnit(e.target.value)} + /> +
+
+ + + + + + + + + +
+
+ + + +
+
+ ); +} diff --git a/frontend/app/(sidebar)/inventory/page.tsx b/frontend/app/(sidebar)/inventory/page.tsx index 290461b..792f3a1 100644 --- a/frontend/app/(sidebar)/inventory/page.tsx +++ b/frontend/app/(sidebar)/inventory/page.tsx @@ -1,29 +1,57 @@ "use client"; -import { useState } from "react"; +import { + useState, + JSXElementConstructor, + ReactElement, + ReactNode, + ReactPortal, + useMemo, +} from "react"; import { useQuery } from "@tanstack/react-query"; -import { Calendar, ChevronDown, Plus, Search } from "lucide-react"; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getPaginationRowModel, + flexRender, + SortingState, + PaginationState, +} from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Pagination, PaginationContent, PaginationItem, PaginationLink } from "@/components/ui/pagination"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Calendar as CalendarComponent } from "@/components/ui/calendar"; -import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Pagination, + PaginationContent, + PaginationItem, +} from "@/components/ui/pagination"; +import { Search } from "lucide-react"; +import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa"; +import { Badge } from "@/components/ui/badge"; import { fetchInventoryItems } from "@/api/inventory"; import { AddInventoryItem } from "./add-inventory-item"; +import { EditInventoryItem } from "./edit-inventory-item"; +import { DeleteInventoryItem } from "./delete-inventory-item"; export default function InventoryPage() { - const [date, setDate] = useState(); - const [inventoryType, setInventoryType] = useState("all"); - const [currentPage, setCurrentPage] = useState(1); + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); - // Fetch inventory items using react-query. const { - data: inventoryItems, + data: inventoryItems = [], isLoading, isError, } = useQuery({ @@ -31,119 +59,180 @@ export default function InventoryPage() { queryFn: fetchInventoryItems, staleTime: 60 * 1000, }); - - if (isLoading) { - return
Loading...
; - } - - if (isError || !inventoryItems) { - return ( -
Error loading inventory data.
+ // console.table(inventoryItems); + const [searchTerm, setSearchTerm] = useState(""); + const filteredItems = useMemo(() => { + return inventoryItems.filter((item) => + item.name.toLowerCase().includes(searchTerm.toLowerCase()) ); - } + }, [inventoryItems, searchTerm]); - // Filter items based on selected type. - const filteredItems = - inventoryType === "all" - ? inventoryItems - : inventoryItems.filter((item) => - inventoryType === "plantation" ? item.type === "Plantation" : item.type === "Fertilizer" + const columns = [ + { accessorKey: "name", header: "Name" }, + { accessorKey: "category", header: "Category" }, + { accessorKey: "type", header: "Type" }, + { accessorKey: "quantity", header: "Quantity" }, + { accessorKey: "lastUpdated", header: "Last Updated" }, + { + accessorKey: "status", + header: "Status", + cell: (info: { getValue: () => string }) => { + const status = info.getValue(); + + let statusClass = ""; // default status class + + if (status === "Low Stock") { + statusClass = "bg-yellow-300"; // yellow for low stock + } else if (status === "Out of Stock") { + statusClass = "bg-red-500 text-white"; // red for out of stock + } + + return ( + + {status} + ); + }, + }, + { + accessorKey: "edit", + header: "Edit", + cell: () => , + enableSorting: false, + }, + { + accessorKey: "delete", + header: "Delete", + cell: () => , + enableSorting: false, + }, + ]; + + const table = useReactTable({ + data: filteredItems, + columns, + state: { sorting, pagination }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + onPaginationChange: setPagination, + }); + + if (isLoading) + return ( +
+ Loading... +
+ ); + if (isError) + return ( +
+ Error loading inventory data. +
+ ); return (

Inventory

- - {/* Filters and search */}
-
- - -
- -
- - - - - - - - - -
- - -
- - -
+ + setSearchTerm(e.target.value)} + /> +
- - {/* Table */}
-

Table Fields

- - Name - Category - Type - Quantity - Last Updated - Status - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {header.column.getCanSort() && + !header.column.columnDef.enableSorting && ( + + {header.column.getIsSorted() === "desc" ? ( + + ) : header.column.getIsSorted() === "asc" ? ( + + ) : ( + + )} + + )} +
+
+ ))} +
+ ))}
- {filteredItems.length === 0 ? ( - - - No inventory items found - + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} - ) : ( - filteredItems.map((item) => ( - - {item.name} - {item.category} - {item.type} - - {item.quantity} {item.unit} - - {item.lastUpdated} - - {item.status} - - - )) - )} + ))}
+ + + + + + + + + + +
diff --git a/frontend/package.json b/frontend/package.json index 4f3ca9d..446d743 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "react-day-picker": "8.10.1", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", + "react-icons": "^5.5.0", "recharts": "^2.15.1", "sonner": "^2.0.1", "tailwind-merge": "^3.0.1",