Merge branch 'main' into feature-knowledge-hub

This commit is contained in:
Sirin Puenggun 2025-04-04 15:34:22 +07:00 committed by GitHub
commit aa87e3e26a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 4974 additions and 3871 deletions

View File

@ -22,11 +22,13 @@ type api struct {
logger *slog.Logger
httpClient *http.Client
userRepo domain.UserRepository
cropRepo domain.CroplandRepository
farmRepo domain.FarmRepository
plantRepo domain.PlantRepository
knowledgeHubRepo domain.KnowledgeHubRepository
userRepo domain.UserRepository
cropRepo domain.CroplandRepository
farmRepo domain.FarmRepository
plantRepo domain.PlantRepository
inventoryRepo domain.InventoryRepository
harvestRepo domain.HarvestRepository
knowledgeHubRepo domain.KnowledgeHubRepository
}
func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api {
@ -38,16 +40,20 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api {
farmRepository := repository.NewPostgresFarm(pool)
plantRepository := repository.NewPostgresPlant(pool)
knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool)
inventoryRepository := repository.NewPostgresInventory(pool)
harvestRepository := repository.NewPostgresHarvest(pool)
return &api{
logger: logger,
httpClient: client,
userRepo: userRepository,
cropRepo: croplandRepository,
farmRepo: farmRepository,
plantRepo: plantRepository,
knowledgeHubRepo: knowledgeHubRepository,
userRepo: userRepository,
cropRepo: croplandRepository,
farmRepo: farmRepository,
plantRepo: plantRepository,
inventoryRepo: inventoryRepository,
harvestRepo: harvestRepository,
knowledgeHubRepo: knowledgeHubRepository,
}
}
@ -88,6 +94,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

View File

@ -0,0 +1,409 @@
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: "getInventoryItemsByUser",
Method: http.MethodGet,
Path: prefix,
Tags: tags,
}, a.getInventoryItemsByUserHandler)
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)
huma.Register(api, huma.Operation{
OperationID: "getInventoryStatus",
Method: http.MethodGet,
Path: prefix + "/status",
Tags: tags,
}, a.getInventoryStatusHandler)
huma.Register(api, huma.Operation{
OperationID: "getInventoryCategory",
Method: http.MethodGet,
Path: prefix + "/category",
Tags: tags,
}, a.getInventoryCategoryHandler)
huma.Register(api, huma.Operation{
OperationID: "getHarvestUnits",
Method: http.MethodGet,
Path: "/harvest/units",
Tags: []string{"harvest"},
}, a.getHarvestUnitsHandler)
}
type InventoryItemResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Category InventoryCategory `json:"category"`
Quantity float64 `json:"quantity"`
Unit HarvestUnit `json:"unit"`
DateAdded time.Time `json:"date_added"`
Status InventoryStatus `json:"status"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type InventoryStatus struct {
ID int `json:"id"`
Name string `json:"name"`
}
type InventoryCategory struct {
ID int `json:"id"`
Name string `json:"name"`
}
type HarvestUnit struct {
ID int `json:"id"`
Name string `json:"name"`
}
type CreateInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
Body struct {
Name string `json:"name" required:"true"`
CategoryID int `json:"category_id" required:"true"`
Quantity float64 `json:"quantity" required:"true"`
UnitID int `json:"unit_id" required:"true"`
DateAdded time.Time `json:"date_added" required:"true"`
StatusID int `json:"status_id" required:"true"`
}
}
type CreateInventoryItemOutput struct {
Body struct {
ID string `json:"id"`
}
}
type UpdateInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
ID string `path:"id"`
Body struct {
Name string `json:"name"`
CategoryID int `json:"category_id"`
Quantity float64 `json:"quantity"`
UnitID int `json:"unit_id"`
DateAdded time.Time `json:"date_added"`
StatusID int `json:"status_id"`
}
}
type UpdateInventoryItemOutput struct {
Body InventoryItemResponse
}
type GetInventoryItemsInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
CategoryID int `query:"category_id"`
StatusID int `query:"status_id"`
StartDate time.Time `query:"start_date" format:"date-time"`
EndDate time.Time `query:"end_date" format:"date-time"`
SearchQuery string `query:"search"`
SortBy string `query:"sort_by" enum:"name,quantity,date_added,created_at"`
SortOrder string `query:"sort_order" enum:"asc,desc" default:"desc"`
}
type GetInventoryItemsOutput struct {
Body []InventoryItemResponse
}
type GetInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
ID string `path:"id"`
}
type GetInventoryItemOutput struct {
Body InventoryItemResponse
}
type DeleteInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
ID string `path:"id"`
}
type DeleteInventoryItemOutput struct {
Body struct {
Message string `json:"message"`
}
}
type GetInventoryStatusOutput struct {
Body []InventoryStatus
}
type GetInventoryCategoryOutput struct {
Body []InventoryCategory
}
type GetHarvestUnitsOutput struct {
Body []HarvestUnit
}
func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInventoryItemInput) (*CreateInventoryItemOutput, error) {
item := &domain.InventoryItem{
UserID: input.UserID,
Name: input.Body.Name,
CategoryID: input.Body.CategoryID,
Quantity: input.Body.Quantity,
UnitID: input.Body.UnitID,
DateAdded: input.Body.DateAdded,
StatusID: input.Body.StatusID,
}
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
}
func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInventoryItemsInput) (*GetInventoryItemsOutput, error) {
filter := domain.InventoryFilter{
UserID: input.UserID,
CategoryID: input.CategoryID,
StatusID: input.StatusID,
StartDate: input.StartDate,
EndDate: input.EndDate,
SearchQuery: input.SearchQuery,
}
sort := domain.InventorySort{
Field: input.SortBy,
Direction: input.SortOrder,
}
items, err := a.inventoryRepo.GetByUserID(ctx, input.UserID, 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: InventoryCategory{
ID: item.Category.ID,
Name: item.Category.Name,
},
Quantity: item.Quantity,
Unit: HarvestUnit{
ID: item.Unit.ID,
Name: item.Unit.Name,
},
DateAdded: item.DateAdded,
Status: InventoryStatus{
ID: item.Status.ID,
Name: item.Status.Name,
},
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
return &GetInventoryItemsOutput{Body: response}, nil
}
func (a *api) getInventoryItemHandler(ctx context.Context, input *GetInventoryItemInput) (*GetInventoryItemOutput, error) {
item, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID)
if err != nil {
return nil, err
}
return &GetInventoryItemOutput{Body: InventoryItemResponse{
ID: item.ID,
Name: item.Name,
Category: InventoryCategory{
ID: item.Category.ID,
Name: item.Category.Name,
},
Quantity: item.Quantity,
Unit: HarvestUnit{
ID: item.Unit.ID,
Name: item.Unit.Name,
},
DateAdded: item.DateAdded,
Status: InventoryStatus{
ID: item.Status.ID,
Name: item.Status.Name,
},
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}}, nil
}
func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInventoryItemInput) (*UpdateInventoryItemOutput, error) {
item, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID)
if err != nil {
return nil, err
}
if input.Body.Name != "" {
item.Name = input.Body.Name
}
if input.Body.CategoryID != 0 {
item.CategoryID = input.Body.CategoryID
}
if input.Body.Quantity != 0 {
item.Quantity = input.Body.Quantity
}
if input.Body.UnitID != 0 {
item.UnitID = input.Body.UnitID
}
if !input.Body.DateAdded.IsZero() {
item.DateAdded = input.Body.DateAdded
}
if input.Body.StatusID != 0 {
item.StatusID = input.Body.StatusID
}
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, input.UserID)
if err != nil {
return nil, err
}
return &UpdateInventoryItemOutput{Body: InventoryItemResponse{
ID: updatedItem.ID,
Name: updatedItem.Name,
Category: InventoryCategory{
ID: updatedItem.Category.ID,
Name: updatedItem.Category.Name,
},
Quantity: updatedItem.Quantity,
Unit: HarvestUnit{
ID: updatedItem.Unit.ID,
Name: updatedItem.Unit.Name,
},
DateAdded: updatedItem.DateAdded,
Status: InventoryStatus{
ID: updatedItem.Status.ID,
Name: updatedItem.Status.Name,
},
CreatedAt: updatedItem.CreatedAt,
UpdatedAt: updatedItem.UpdatedAt,
}}, nil
}
func (a *api) deleteInventoryItemHandler(ctx context.Context, input *DeleteInventoryItemInput) (*DeleteInventoryItemOutput, error) {
err := a.inventoryRepo.Delete(ctx, input.ID, input.UserID)
if err != nil {
return nil, err
}
return &DeleteInventoryItemOutput{Body: struct {
Message string `json:"message"`
}{Message: "Inventory item deleted successfully"}}, nil
}
func (a *api) getInventoryStatusHandler(ctx context.Context, input *struct{}) (*GetInventoryStatusOutput, error) {
statuses, err := a.inventoryRepo.GetStatuses(ctx)
if err != nil {
return nil, err
}
response := make([]InventoryStatus, len(statuses))
for i, status := range statuses {
response[i] = InventoryStatus{
ID: status.ID,
Name: status.Name,
}
}
return &GetInventoryStatusOutput{Body: response}, nil
}
func (a *api) getInventoryCategoryHandler(ctx context.Context, input *struct{}) (*GetInventoryCategoryOutput, error) {
categories, err := a.inventoryRepo.GetCategories(ctx)
if err != nil {
return nil, err
}
response := make([]InventoryCategory, len(categories))
for i, category := range categories {
response[i] = InventoryCategory{
ID: category.ID,
Name: category.Name,
}
}
return &GetInventoryCategoryOutput{Body: response}, nil
}
func (a *api) getHarvestUnitsHandler(ctx context.Context, input *struct{}) (*GetHarvestUnitsOutput, error) {
units, err := a.harvestRepo.GetUnits(ctx)
if err != nil {
return nil, err
}
response := make([]HarvestUnit, len(units))
for i, unit := range units {
response[i] = HarvestUnit{
ID: unit.ID,
Name: unit.Name,
}
}
return &GetHarvestUnitsOutput{Body: response}, nil
}

View File

@ -0,0 +1,79 @@
package domain
import (
"context"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
)
type InventoryStatus struct {
ID int `json:"id"`
Name string `json:"name"`
}
type InventoryCategory struct {
ID int `json:"id"`
Name string `json:"name"`
}
type HarvestUnit struct {
ID int `json:"id"`
Name string `json:"name"`
}
type InventoryItem struct {
ID string
UserID string
Name string
CategoryID int
Category InventoryCategory
Quantity float64
UnitID int
Unit HarvestUnit
DateAdded time.Time
StatusID int
Status InventoryStatus
CreatedAt time.Time
UpdatedAt time.Time
}
type InventoryFilter struct {
UserID string
CategoryID int
StatusID int
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.UserID, validation.Required),
validation.Field(&i.Name, validation.Required),
validation.Field(&i.CategoryID, validation.Required),
validation.Field(&i.Quantity, validation.Required, validation.Min(0.0)),
validation.Field(&i.UnitID, validation.Required),
validation.Field(&i.StatusID, validation.Required),
validation.Field(&i.DateAdded, validation.Required),
)
}
type InventoryRepository interface {
GetByID(ctx context.Context, id, userID string) (InventoryItem, error)
GetByUserID(ctx context.Context, userID string, filter InventoryFilter, sort InventorySort) ([]InventoryItem, error)
GetAll(ctx context.Context) ([]InventoryItem, error)
CreateOrUpdate(ctx context.Context, item *InventoryItem) error
Delete(ctx context.Context, id, userID string) error
GetStatuses(ctx context.Context) ([]InventoryStatus, error)
GetCategories(ctx context.Context) ([]InventoryCategory, error)
}
type HarvestRepository interface {
GetUnits(ctx context.Context) ([]HarvestUnit, error)
}

View File

@ -0,0 +1,34 @@
package repository
import (
"context"
"github.com/forfarm/backend/internal/domain"
)
type postgresHarvestRepository struct {
conn Connection
}
func NewPostgresHarvest(conn Connection) domain.HarvestRepository {
return &postgresHarvestRepository{conn: conn}
}
func (p *postgresHarvestRepository) GetUnits(ctx context.Context) ([]domain.HarvestUnit, error) {
query := `SELECT id, name FROM harvest_units ORDER BY id`
rows, err := p.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var units []domain.HarvestUnit
for rows.Next() {
var u domain.HarvestUnit
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, err
}
units = append(units, u)
}
return units, nil
}

View File

@ -0,0 +1,314 @@
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.UserID,
&i.Name,
&i.CategoryID,
&i.Quantity,
&i.UnitID,
&i.DateAdded,
&i.StatusID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
return items, nil
}
func (p *postgresInventoryRepository) GetByID(ctx context.Context, id, userID string) (domain.InventoryItem, error) {
query := `
SELECT
i.id, i.user_id, i.name, i.category_id, i.quantity, i.unit_id,
i.date_added, i.status_id, i.created_at, i.updated_at,
c.name as category_name,
s.name as status_name,
u.name as unit_name
FROM inventory_items i
LEFT JOIN inventory_category c ON i.category_id = c.id
LEFT JOIN inventory_status s ON i.status_id = s.id
LEFT JOIN harvest_units u ON i.unit_id = u.id
WHERE i.id = $1 AND i.user_id = $2`
rows, err := p.conn.Query(ctx, query, id, userID)
if err != nil {
return domain.InventoryItem{}, err
}
defer rows.Close()
if !rows.Next() {
return domain.InventoryItem{}, domain.ErrNotFound
}
var item domain.InventoryItem
err = rows.Scan(
&item.ID,
&item.UserID,
&item.Name,
&item.CategoryID,
&item.Quantity,
&item.UnitID,
&item.DateAdded,
&item.StatusID,
&item.CreatedAt,
&item.UpdatedAt,
&item.Category.Name,
&item.Status.Name,
&item.Unit.Name,
)
if err != nil {
return domain.InventoryItem{}, err
}
return item, nil
}
func (p *postgresInventoryRepository) GetByUserID(
ctx context.Context,
userID string,
filter domain.InventoryFilter,
sort domain.InventorySort,
) ([]domain.InventoryItem, error) {
var query strings.Builder
args := []interface{}{userID}
argPos := 2
query.WriteString(`
SELECT
i.id, i.user_id, i.name, i.category_id, i.quantity, i.unit_id,
i.date_added, i.status_id, i.created_at, i.updated_at,
c.name as category_name,
s.name as status_name,
u.name as unit_name
FROM inventory_items i
LEFT JOIN inventory_category c ON i.category_id = c.id
LEFT JOIN inventory_status s ON i.status_id = s.id
LEFT JOIN harvest_units u ON i.unit_id = u.id
WHERE i.user_id = $1`)
if filter.CategoryID != 0 {
query.WriteString(fmt.Sprintf(" AND i.category_id = $%d", argPos))
args = append(args, filter.CategoryID)
argPos++
}
if filter.StatusID != 0 {
query.WriteString(fmt.Sprintf(" AND i.status_id = $%d", argPos))
args = append(args, filter.StatusID)
argPos++
}
if !filter.StartDate.IsZero() {
query.WriteString(fmt.Sprintf(" AND i.date_added >= $%d", argPos))
args = append(args, filter.StartDate)
argPos++
}
if !filter.EndDate.IsZero() {
query.WriteString(fmt.Sprintf(" AND i.date_added <= $%d", argPos))
args = append(args, filter.EndDate)
argPos++
}
if filter.SearchQuery != "" {
query.WriteString(fmt.Sprintf(" AND i.name ILIKE $%d", argPos))
args = append(args, "%"+filter.SearchQuery+"%")
argPos++
}
if sort.Field == "" {
sort.Field = "i.date_added"
sort.Direction = "desc"
}
validSortFields := map[string]bool{
"name": true,
"quantity": true,
"date_added": 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")
}
}
rows, err := p.conn.Query(ctx, query.String(), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []domain.InventoryItem
for rows.Next() {
var item domain.InventoryItem
err := rows.Scan(
&item.ID,
&item.UserID,
&item.Name,
&item.CategoryID,
&item.Quantity,
&item.UnitID,
&item.DateAdded,
&item.StatusID,
&item.CreatedAt,
&item.UpdatedAt,
&item.Category.Name,
&item.Status.Name,
&item.Unit.Name,
)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
func (p *postgresInventoryRepository) GetAll(ctx context.Context) ([]domain.InventoryItem, error) {
query := `
SELECT
i.id, i.user_id, i.name, i.category_id, i.quantity, i.unit_id,
i.date_added, i.status_id, i.created_at, i.updated_at,
c.name as category_name,
s.name as status_name,
u.name as unit_name
FROM inventory_items i
LEFT JOIN inventory_category c ON i.category_id = c.id
LEFT JOIN inventory_status s ON i.status_id = s.id
LEFT JOIN harvest_units u ON i.unit_id = u.id
ORDER BY i.created_at DESC`
return p.fetch(ctx, query)
}
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, user_id, name, category_id, quantity, unit_id, date_added, status_id, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`
return p.conn.QueryRow(
ctx,
query,
item.UserID,
item.Name,
item.CategoryID,
item.Quantity,
item.UnitID,
item.DateAdded,
item.StatusID,
item.CreatedAt,
item.UpdatedAt,
).Scan(&item.ID)
}
query := `
UPDATE inventory_items
SET name = $1,
category_id = $2,
quantity = $3,
unit_id = $4,
date_added = $5,
status_id = $6,
updated_at = $7
WHERE id = $8 AND user_id = $9
RETURNING id`
return p.conn.QueryRow(
ctx,
query,
item.Name,
item.CategoryID,
item.Quantity,
item.UnitID,
item.DateAdded,
item.StatusID,
item.UpdatedAt,
item.ID,
item.UserID,
).Scan(&item.ID)
}
func (p *postgresInventoryRepository) Delete(ctx context.Context, id, userID string) error {
query := `DELETE FROM inventory_items WHERE id = $1 AND user_id = $2`
_, err := p.conn.Exec(ctx, query, id, userID)
return err
}
func (p *postgresInventoryRepository) GetStatuses(ctx context.Context) ([]domain.InventoryStatus, error) {
query := `SELECT id, name FROM inventory_status ORDER BY id`
rows, err := p.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var statuses []domain.InventoryStatus
for rows.Next() {
var s domain.InventoryStatus
if err := rows.Scan(&s.ID, &s.Name); err != nil {
return nil, err
}
statuses = append(statuses, s)
}
return statuses, nil
}
func (p *postgresInventoryRepository) GetCategories(ctx context.Context) ([]domain.InventoryCategory, error) {
query := `SELECT id, name FROM inventory_category ORDER BY id`
rows, err := p.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var categories []domain.InventoryCategory
for rows.Next() {
var c domain.InventoryCategory
if err := rows.Scan(&c.ID, &c.Name); err != nil {
return nil, err
}
categories = append(categories, c)
}
return categories, nil
}

View File

@ -12,7 +12,7 @@ CREATE TABLE soil_conditions (
CREATE TABLE harvest_units (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
);
CREATE TABLE plants (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),

View File

@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE inventory_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL,
type TEXT NOT NULL,
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(),
CONSTRAINT fk_inventory_items_user FOREIGN KEY (user_id) REFERENCES users(uuid) ON DELETE CASCADE
);
-- Create indexes
CREATE INDEX idx_inventory_items_user_id ON inventory_items(user_id);
CREATE INDEX idx_inventory_items_user_category ON inventory_items(user_id, category);
CREATE INDEX idx_inventory_items_user_status ON inventory_items(user_id, status);

View File

@ -0,0 +1,5 @@
-- +goose Up
CREATE TABLE inventory_status (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);

View File

@ -0,0 +1,15 @@
-- +goose Up
ALTER TABLE inventory_items
ADD COLUMN status_id INT;
UPDATE inventory_items
SET status_id = (SELECT id FROM inventory_status WHERE name = inventory_items.status);
ALTER TABLE inventory_items
DROP COLUMN status;
ALTER TABLE inventory_items
ADD CONSTRAINT fk_inventory_items_status FOREIGN KEY (status_id) REFERENCES inventory_status(id) ON DELETE CASCADE;
CREATE INDEX idx_inventory_items_status_id ON inventory_items(status_id);

View File

@ -0,0 +1,7 @@
-- +goose Up
-- Insert default statuses into the inventory_status table
INSERT INTO inventory_status (name)
VALUES
('In Stock'),
('Low Stock'),
('Out Of Stock');

View File

@ -0,0 +1,70 @@
-- +goose Up
-- Step 1: Create inventory_category table
CREATE TABLE inventory_category (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
-- Step 2: Insert sample categories
INSERT INTO inventory_category (name)
VALUES
('Seeds'),
('Tools'),
('Chemicals');
-- Step 3: Add category_id column to inventory_items
ALTER TABLE inventory_items
ADD COLUMN category_id INT;
-- Step 4: Link inventory_items to inventory_category
ALTER TABLE inventory_items
ADD CONSTRAINT fk_inventory_category FOREIGN KEY (category_id) REFERENCES inventory_category(id) ON DELETE SET NULL;
-- Step 5: Remove old columns (type, category, unit) from inventory_items
ALTER TABLE inventory_items
DROP COLUMN type,
DROP COLUMN category,
DROP COLUMN unit;
-- Step 6: Add unit_id column to inventory_items
ALTER TABLE inventory_items
ADD COLUMN unit_id INT;
-- Step 7: Link inventory_items to harvest_units
ALTER TABLE inventory_items
ADD CONSTRAINT fk_inventory_unit FOREIGN KEY (unit_id) REFERENCES harvest_units(id) ON DELETE SET NULL;
-- Step 8: Insert new unit values into harvest_units
INSERT INTO harvest_units (name)
VALUES
('Tonne'),
('KG');
-- +goose Down
-- Reverse Step 8: Remove inserted unit values
DELETE FROM harvest_units WHERE name IN ('Tonne', 'KG');
-- Reverse Step 7: Remove the foreign key constraint
ALTER TABLE inventory_items
DROP CONSTRAINT fk_inventory_unit;
-- Reverse Step 6: Remove unit_id column from inventory_items
ALTER TABLE inventory_items
DROP COLUMN unit_id;
-- Reverse Step 5: Add back type, category, and unit columns
ALTER TABLE inventory_items
ADD COLUMN type TEXT NOT NULL,
ADD COLUMN category TEXT NOT NULL,
ADD COLUMN unit TEXT NOT NULL;
-- Reverse Step 4: Remove foreign key constraint from inventory_items
ALTER TABLE inventory_items
DROP CONSTRAINT fk_inventory_category;
-- Reverse Step 3: Remove category_id column from inventory_items
ALTER TABLE inventory_items
DROP COLUMN category_id;
-- Reverse Step 2: Drop inventory_category table
DROP TABLE inventory_category;

View File

@ -1,11 +1,29 @@
import axiosInstance from "./config";
import type { InventoryItem, CreateInventoryItemInput } from "@/types";
import type {
InventoryItem,
CreateInventoryItemInput,
InventoryItemStatus,
} from "@/types";
/**
* Simulates an API call to fetch inventory items.
* Waits for a simulated delay and then attempts an axios GET request.
* If the request fails, returns fallback dummy data.
*
*
*/
export async function fetchInventoryStatus(): Promise<InventoryItemStatus[]> {
try {
const response = await axiosInstance.get<InventoryItemStatus[]>(
"/inventory/status"
);
return response.data;
} catch (error) {
console.error("Error fetching inventory status:", error);
return [];
}
}
export async function fetchInventoryItems(): Promise<InventoryItem[]> {
try {
const response = await axiosInstance.get<InventoryItem[]>("/api/inventory");
@ -51,7 +69,7 @@ export async function fetchInventoryItems(): Promise<InventoryItem[]> {
quantity: 150,
unit: "kg",
lastUpdated: "2023-03-15",
status: "In Stock",
status: "Out Of Stock",
},
{
id: 5,
@ -79,7 +97,10 @@ export async function createInventoryItem(
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
try {
const response = await axiosInstance.post<InventoryItem>("/api/inventory", item);
const response = await axiosInstance.post<InventoryItem>(
"/api/inventory",
item
);
return response.data;
} catch (error) {
// Simulate successful creation if API endpoint is not available

View File

@ -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<CropDetailPageParams> }) {
export default function CropDetailPage({
params,
}: {
params: Promise<CropDetailPageParams>;
}) {
const router = useRouter();
const [crop, setCrop] = useState<Crop | null>(null);
const [analytics, setAnalytics] = useState<CropAnalytics | null>(null);
@ -57,7 +71,9 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
if (!crop || !analytics) {
return (
<div className="min-h-screen flex items-center justify-center bg-background text-foreground">Loading...</div>
<div className="min-h-screen flex items-center justify-center bg-background text-foreground">
Loading...
</div>
);
}
@ -87,7 +103,8 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
icon: ListCollapse,
description: "View detailed information",
onClick: () => console.log("Details clicked"),
color: "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300",
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<CropDetailP
<Button
variant="ghost"
className="gap-2 text-green-700 dark:text-green-300 hover:text-green-800 dark:hover:text-green-200 hover:bg-green-100/50 dark:hover:bg-green-800/50"
onClick={() => router.back()}>
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4" /> Back to Farm
</Button>
<HoverCard>
@ -126,7 +144,9 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
</Avatar>
<div className="space-y-1">
<h4 className="text-sm font-semibold">Growth Timeline</h4>
<p className="text-sm text-muted-foreground">Planted on {crop.plantedDate.toLocaleDateString()}</p>
<p className="text-sm text-muted-foreground">
Planted on {crop.plantedDate.toLocaleDateString()}
</p>
<div className="flex items-center pt-2">
<Separator className="w-full" />
<span className="mx-2 text-xs text-muted-foreground">
@ -150,19 +170,28 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
<div className="flex items-center gap-4">
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<Badge variant="outline" className={`${healthColors[analytics.plantHealth]} border`}>
<Badge
variant="outline"
className={`${healthColors[analytics.plantHealth]} border`}
>
Health Score: {crop.healthScore}%
</Badge>
<Badge variant="outline" className="bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300">
<Badge
variant="outline"
className="bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300"
>
Growing
</Badge>
</div>
{crop.expectedHarvest ? (
<p className="text-sm text-muted-foreground mt-1">
Expected harvest: {crop.expectedHarvest.toLocaleDateString()}
Expected harvest:{" "}
{crop.expectedHarvest.toLocaleDateString()}
</p>
) : (
<p className="text-sm text-muted-foreground mt-1">Expected harvest date not available</p>
<p className="text-sm text-muted-foreground mt-1">
Expected harvest date not available
</p>
)}
</div>
</div>
@ -180,13 +209,18 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
key={action.title}
variant="outline"
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105`}
onClick={action.onClick}>
<div className={`p-3 rounded-lg ${action.color} group-hover:scale-110 transition-transform`}>
onClick={action.onClick}
>
<div
className={`p-3 rounded-lg ${action.color} group-hover:scale-110 transition-transform`}
>
<action.icon className="h-5 w-5" />
</div>
<div className="text-center">
<div className="font-medium mb-1">{action.title}</div>
<p className="text-xs text-muted-foreground">{action.description}</p>
<p className="text-xs text-muted-foreground">
{action.description}
</p>
</div>
</Button>
))}
@ -196,7 +230,9 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
<Card className="border-green-100 dark:border-green-700">
<CardHeader>
<CardTitle>Environmental Conditions</CardTitle>
<CardDescription>Real-time monitoring of growing conditions</CardDescription>
<CardDescription>
Real-time monitoring of growing conditions
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6">
@ -247,15 +283,22 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
].map((metric) => (
<Card
key={metric.label}
className="border-none shadow-none bg-gradient-to-br from-white to-gray-50/50 dark:from-slate-800 dark:to-slate-700/50">
className="border-none shadow-none bg-gradient-to-br from-white to-gray-50/50 dark:from-slate-800 dark:to-slate-700/50"
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-lg ${metric.bg}`}>
<metric.icon className={`h-4 w-4 ${metric.color}`} />
<metric.icon
className={`h-4 w-4 ${metric.color}`}
/>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{metric.label}</p>
<p className="text-2xl font-semibold tracking-tight">{metric.value}</p>
<p className="text-sm font-medium text-muted-foreground">
{metric.label}
</p>
<p className="text-2xl font-semibold tracking-tight">
{metric.value}
</p>
</div>
</div>
</CardContent>
@ -269,9 +312,14 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">Growth Progress</span>
<span className="text-muted-foreground">{analytics.growthProgress}%</span>
<span className="text-muted-foreground">
{analytics.growthProgress}%
</span>
</div>
<Progress value={analytics.growthProgress} className="h-2" />
<Progress
value={analytics.growthProgress}
className="h-2"
/>
</div>
{/* Next Action Card */}
@ -282,10 +330,15 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
<Timer className="h-4 w-4 text-green-600 dark:text-green-300" />
</div>
<div>
<p className="font-medium mb-1">Next Action Required</p>
<p className="text-sm text-muted-foreground">{analytics.nextAction}</p>
<p className="font-medium mb-1">
Next Action Required
</p>
<p className="text-sm text-muted-foreground">
{analytics.nextAction}
</p>
<p className="text-xs text-muted-foreground mt-1">
Due by {analytics.nextActionDue.toLocaleDateString()}
Due by{" "}
{analytics.nextActionDue.toLocaleDateString()}
</p>
</div>
</div>
@ -337,9 +390,14 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
<div key={nutrient.name} className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">{nutrient.name}</span>
<span className="text-muted-foreground">{nutrient.value}%</span>
<span className="text-muted-foreground">
{nutrient.value}%
</span>
</div>
<Progress value={nutrient.value} className={`h-2 ${nutrient.color}`} />
<Progress
value={nutrient.value}
className={`h-2 ${nutrient.color}`}
/>
</div>
))}
</div>
@ -372,10 +430,14 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
][i]
}
</p>
<p className="text-xs text-muted-foreground">2 hours ago</p>
<p className="text-xs text-muted-foreground">
2 hours ago
</p>
</div>
</div>
{i < 4 && <Separator className="my-4 dark:bg-slate-700" />}
{i < 4 && (
<Separator className="my-4 dark:bg-slate-700" />
)}
</div>
))}
</ScrollArea>
@ -385,8 +447,17 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
</div>
{/* Dialogs */}
<ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={crop.name} />
<AnalyticsDialog open={isAnalyticsOpen} onOpenChange={setIsAnalyticsOpen} crop={crop} analytics={analytics} />
<ChatbotDialog
open={isChatOpen}
onOpenChange={setIsChatOpen}
cropName={crop.name}
/>
<AnalyticsDialog
open={isAnalyticsOpen}
onOpenChange={setIsAnalyticsOpen}
crop={crop}
analytics={analytics}
/>
</div>
</div>
);
@ -399,9 +470,15 @@ function Activity({ icon }: { icon: number }) {
const icons = [
<Droplets key="0" className="h-4 w-4 text-blue-500 dark:text-blue-300" />,
<Leaf key="1" className="h-4 w-4 text-green-500 dark:text-green-300" />,
<LineChart key="2" className="h-4 w-4 text-purple-500 dark:text-purple-300" />,
<LineChart
key="2"
className="h-4 w-4 text-purple-500 dark:text-purple-300"
/>,
<Sprout key="3" className="h-4 w-4 text-yellow-500 dark:text-yellow-300" />,
<ThermometerSun key="4" className="h-4 w-4 text-orange-500 dark:text-orange-300" />,
<ThermometerSun
key="4"
className="h-4 w-4 text-orange-500 dark:text-orange-300"
/>,
];
return icons[icon];
}

View File

@ -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<Date | undefined>();
const [open, setOpen] = useState(false);
const [itemName, setItemName] = useState("");
const [itemType, setItemType] = useState("");
const [itemCategory, setItemCategory] = useState("");
const [itemQuantity, setItemQuantity] = useState(0);
const [itemUnit, setItemUnit] = useState("");
// const queryClient = useQueryClient();
const handleDelete = () => {
// handle delete item
};
return (
<Button
type="submit"
className="bg-red-500 hover:bg-red-800 text-white"
onClick={handleDelete}
>
Delete Item
</Button>
);
}

View File

@ -0,0 +1,203 @@
"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 interface EditInventoryItemProps {
id: string;
name: string;
category: string;
status: string;
type: string;
unit: string;
quantity: number;
}
export function EditInventoryItem({
id,
name,
category,
status,
type,
unit,
quantity,
}: EditInventoryItemProps) {
const [open, setOpen] = useState(false);
const [itemName, setItemName] = useState(name);
const [itemType, setItemType] = useState(type);
const [itemCategory, setItemCategory] = useState(category);
const [itemQuantity, setItemQuantity] = useState(quantity);
const [itemUnit, setItemUnit] = useState(unit);
const [itemStatus, setItemStatus] = useState(status);
// const 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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-yellow-500 hover:bg-yellow-600">Edit</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Inventory Item</DialogTitle>
<DialogDescription>
Edit a plantation or fertilizer item in your inventory.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
className="col-span-3"
value={itemName}
onChange={(e) => setItemName(e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">
Type
</Label>
<Select value={itemType.toLowerCase()} onValueChange={setItemType}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Type</SelectLabel>
<SelectItem value="plantation">Plantation</SelectItem>
<SelectItem value="fertilizer">Fertilizer</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">
Status
</Label>
<Select
value={itemStatus.toLowerCase()}
onValueChange={setItemStatus}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Status</SelectLabel>
<SelectItem value="in stock">In Stock</SelectItem>
<SelectItem value="low stock">Low Stock</SelectItem>
<SelectItem value="out of stock">Out Of Stock</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="category" className="text-right">
Category
</Label>
<Input
id="category"
className="col-span-3"
placeholder="e.g., Seeds, Organic"
value={itemCategory}
onChange={(e) => setItemCategory(e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="quantity" className="text-right">
Quantity
</Label>
<Input
id="quantity"
type="number"
className="col-span-3"
value={itemQuantity}
onChange={(e) => setItemQuantity(Number(e.target.value))}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="unit" className="text-right">
Unit
</Label>
<Input
id="unit"
className="col-span-3"
placeholder="e.g., kg, packets"
value={itemUnit}
onChange={(e) => setItemUnit(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={handleEdit}>
Edit Item
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,149 +1,252 @@
"use client";
import { useState } from "react";
import { useState, 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 { fetchInventoryItems } from "@/api/inventory";
import { Badge } from "@/components/ui/badge";
import { fetchInventoryItems, fetchInventoryStatus } from "@/api/inventory";
import { AddInventoryItem } from "./add-inventory-item";
import {
EditInventoryItem,
EditInventoryItemProps,
} from "./edit-inventory-item";
import { DeleteInventoryItem } from "./delete-inventory-item";
export default function InventoryPage() {
const [date, setDate] = useState<Date>();
const [inventoryType, setInventoryType] = useState("all");
const [currentPage, setCurrentPage] = useState(1);
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
// Fetch inventory items using react-query.
const {
data: inventoryItems,
isLoading,
isError,
data: inventoryItems = [],
isLoading: isItemLoading,
isError: isItemError,
} = useQuery({
queryKey: ["inventoryItems"],
queryFn: fetchInventoryItems,
staleTime: 60 * 1000,
});
if (isLoading) {
return <div className="flex min-h-screen bg-background items-center justify-center">Loading...</div>;
}
const {
data: inventoryStatus = [],
isLoading: isLoadingStatus,
isError: isErrorStatus,
} = useQuery({
queryKey: ["inventoryStatus"],
queryFn: fetchInventoryStatus,
staleTime: 60 * 1000,
});
// console.table(inventoryItems);
console.table(inventoryStatus);
const [searchTerm, setSearchTerm] = useState("");
const filteredItems = useMemo(() => {
return inventoryItems
.map((item) => ({
...item,
id: String(item.id), // Convert `id` to string here
}))
.filter((item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [inventoryItems, searchTerm]);
if (isError || !inventoryItems) {
return (
<div className="flex min-h-screen bg-background items-center justify-center">Error loading inventory data.</div>
);
}
const columns = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "category", header: "Category" },
{ accessorKey: "quantity", header: "Quantity" },
{ accessorKey: "unit", header: "Unit" },
{ accessorKey: "lastUpdated", header: "Last Updated" },
{
accessorKey: "status",
header: "Status",
cell: (info: { getValue: () => string }) => {
const status = info.getValue();
// Filter items based on selected type.
const filteredItems =
inventoryType === "all"
? inventoryItems
: inventoryItems.filter((item) =>
inventoryType === "plantation" ? item.type === "Plantation" : item.type === "Fertilizer"
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 (
<Badge className={`py-1 px-2 rounded-md ${statusClass}`}>
{status}
</Badge>
);
},
},
{
accessorKey: "edit",
header: "Edit",
cell: ({ row }: { row: { original: EditInventoryItemProps } }) => (
<EditInventoryItem {...row.original} />
),
enableSorting: false,
},
{
accessorKey: "delete",
header: "Delete",
cell: () => <DeleteInventoryItem />,
enableSorting: false,
},
];
const table = useReactTable({
data: filteredItems,
columns,
state: { sorting, pagination },
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
onPaginationChange: setPagination,
});
if (isItemLoading || isLoadingStatus)
return (
<div className="flex min-h-screen items-center justify-center">
Loading...
</div>
);
if (isItemError || isErrorStatus)
return (
<div className="flex min-h-screen items-center justify-center">
Error loading inventory data.
</div>
);
return (
<div className="flex min-h-screen bg-background">
<div className="flex-1 flex flex-col">
<main className="flex-1 p-6">
<h1 className="text-2xl font-bold tracking-tight mb-6">Inventory</h1>
{/* Filters and search */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex gap-2">
<Button
variant={inventoryType === "all" ? "default" : "outline"}
onClick={() => setInventoryType("all")}
className="w-24">
All
</Button>
<Select value={inventoryType} onValueChange={setInventoryType}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Crop" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All</SelectItem>
<SelectItem value="plantation">Plantation</SelectItem>
<SelectItem value="fertilizer">Fertilizer</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-1 gap-4">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="flex-1 justify-between">
<div className="flex items-center">
<Calendar className="mr-2 h-4 w-4" />
{date ? date.toLocaleDateString() : "Time filter"}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<CalendarComponent mode="single" selected={date} onSelect={setDate} initialFocus />
</PopoverContent>
</Popover>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input type="search" placeholder="Search Farms" className="pl-8" />
</div>
<AddInventoryItem />
</div>
<Search className="mt-1" />
<Input
type="search"
placeholder="Search by name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<AddInventoryItem />
</div>
{/* Table */}
<div className="border rounded-md">
<h3 className="px-4 py-2 border-b font-medium">Table Fields</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Quantity</TableHead>
<TableHead>Last Updated</TableHead>
<TableHead>Status</TableHead>
</TableRow>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="cursor-pointer "
>
<div className="flex items-center">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getCanSort() &&
!header.column.columnDef.enableSorting && (
<span className="ml-2">
{header.column.getIsSorted() === "desc" ? (
<FaSortDown />
) : header.column.getIsSorted() === "asc" ? (
<FaSortUp />
) : (
<FaSort />
)}
</span>
)}
</div>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{filteredItems.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No inventory items found
</TableCell>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="even:bg-gray-800">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
) : (
filteredItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.category}</TableCell>
<TableCell>{item.type}</TableCell>
<TableCell className="text-right">
{item.quantity} {item.unit}
</TableCell>
<TableCell>{item.lastUpdated}</TableCell>
<TableCell>
<Badge variant={item.status === "Low Stock" ? "destructive" : "default"}>{item.status}</Badge>
</TableCell>
</TableRow>
))
)}
))}
</TableBody>
</Table>
</div>
<Pagination className="mt-5">
<PaginationContent className="space-x-5">
<PaginationItem>
<Button
className="flex w-24"
onClick={() =>
setPagination((prev) => ({
...prev,
pageIndex: Math.max(0, prev.pageIndex - 1),
}))
}
disabled={pagination.pageIndex === 0}
>
Previous
</Button>
</PaginationItem>
<PaginationItem>
<Button
className="flex w-24"
onClick={() =>
setPagination((prev) => ({
...prev,
pageIndex: prev.pageIndex + 1,
}))
}
disabled={
pagination.pageIndex >=
Math.ceil(filteredItems.length / pagination.pageSize) - 1
}
>
Next
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</main>
</div>
</div>

View File

@ -20,17 +20,37 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
type harvestSchema = z.infer<typeof harvestDetailsFormSchema>;
export default function HarvestDetailsForm() {
export default function HarvestDetailsForm({
onChange,
}: {
onChange: (data: harvestSchema) => void;
}) {
const form = useForm<harvestSchema>({
resolver: zodResolver(harvestDetailsFormSchema),
defaultValues: {},
defaultValues: {
daysToFlower: 0,
daysToMaturity: 0,
harvestWindow: 0,
estimatedLossRate: 0,
harvestUnits: "",
estimatedRevenue: 0,
expectedYieldPer100ft: 0,
expectedYieldPerAcre: 0,
},
});
const onSubmit: (data: harvestSchema) => void = (data) => {
onChange(data);
};
return (
<Form {...form}>
<form className="grid grid-cols-3 gap-5">
<form
className="grid grid-cols-3 gap-5"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="daysToFlower"
@ -47,6 +67,13 @@ export default function HarvestDetailsForm() {
id="daysToFlower"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -71,6 +98,13 @@ export default function HarvestDetailsForm() {
id="daysToMaturity"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -95,6 +129,13 @@ export default function HarvestDetailsForm() {
id="harvestWindow"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -119,6 +160,13 @@ export default function HarvestDetailsForm() {
id="estimatedLossRate"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -168,6 +216,13 @@ export default function HarvestDetailsForm() {
id="estimatedRevenue"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -192,6 +247,13 @@ export default function HarvestDetailsForm() {
id="expectedYieldPer100ft"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -216,6 +278,13 @@ export default function HarvestDetailsForm() {
id="expectedYieldPerAcre"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -224,6 +293,9 @@ export default function HarvestDetailsForm() {
</FormItem>
)}
/>
<div className="col-span-3 flex justify-center">
<Button type="submit">Save</Button>
</div>
</form>
</Form>
);

View File

@ -1,34 +1,124 @@
"use client";
import { SetStateAction, useEffect, useState } from "react";
import PlantingDetailsForm from "./planting-detail-form";
import HarvestDetailsForm from "./harvest-detail-form";
import { Separator } from "@/components/ui/separator";
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
import {
plantingDetailsFormSchema,
harvestDetailsFormSchema,
} from "@/schemas/application.schema";
import { z } from "zod";
type plantingSchema = z.infer<typeof plantingDetailsFormSchema>;
type harvestSchema = z.infer<typeof harvestDetailsFormSchema>;
export default function SetupPage() {
const [plantingDetails, setPlantingDetails] = useState<plantingSchema | null>(
null
);
const [harvestDetails, setHarvestDetails] = useState<harvestSchema | null>(
null
);
const [mapData, setMapData] = useState<{ lat: number; lng: number }[] | null>(
null
);
// handle planting details submission
const handlePlantingDetailsChange = (data: plantingSchema) => {
setPlantingDetails(data);
};
// handle harvest details submission
const handleHarvestDetailsChange = (data: harvestSchema) => {
setHarvestDetails(data);
};
// handle map area selection
const handleMapDataChange = (data: { lat: number; lng: number }[]) => {
setMapData((prevMapData) => {
if (prevMapData) {
return [...prevMapData, ...data];
} else {
return data;
}
});
};
// log the changes
useEffect(() => {
// console.log(plantingDetails);
// console.log(harvestDetails);
console.table(mapData);
}, [plantingDetails, harvestDetails, mapData]);
const handleSubmit = () => {
if (!plantingDetails || !harvestDetails || !mapData) {
alert("Please complete all sections before submitting.");
return;
}
const formData = {
plantingDetails,
harvestDetails,
mapData,
};
console.log("Form data to be submitted:", formData);
fetch("/api/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
})
.then((response) => response.json())
.then((data) => {
console.log("Response from backend:", data);
})
.catch((error) => {
console.error("Error submitting form:", error);
});
};
return (
<div className="p-5">
<div className=" flex justify-center">
<h1 className="flex text-2xl ">Plating Details</h1>
{/* Planting Details Section */}
<div className="flex justify-center">
<h1 className="text-2xl">Planting Details</h1>
</div>
<Separator className="mt-3" />
<div className="mt-10 flex justify-center">
<PlantingDetailsForm />
<PlantingDetailsForm onChange={handlePlantingDetailsChange} />
</div>
<div className=" flex justify-center mt-20">
<h1 className="flex text-2xl ">Harvest Details</h1>
{/* Harvest Details Section */}
<div className="flex justify-center mt-20">
<h1 className="text-2xl">Harvest Details</h1>
</div>
<Separator className="mt-3" />
<div className="mt-10 flex justify-center">
<HarvestDetailsForm />
<HarvestDetailsForm onChange={handleHarvestDetailsChange} />
</div>
{/* Map Section */}
<div className="mt-10">
<div className=" flex justify-center mt-20">
<h1 className="flex text-2xl ">Map</h1>
<div className="flex justify-center mt-20">
<h1 className="text-2xl">Map</h1>
</div>
<Separator className="mt-3" />
<div className="mt-10">
<GoogleMapWithDrawing />
<GoogleMapWithDrawing onAreaSelected={handleMapDataChange} />
</div>
</div>
{/* Submit Button */}
<div className="mt-10 flex justify-center">
<button onClick={handleSubmit} className="btn btn-primary">
Submit All Data
</button>
</div>
</div>
);
}

View File

@ -22,17 +22,42 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
type plantingSchema = z.infer<typeof plantingDetailsFormSchema>;
export default function PlantingDetailsForm() {
const form = useForm<plantingSchema>({
export default function PlantingDetailsForm({
onChange,
}: {
onChange: (data: plantingSchema) => void;
}) {
const form = useForm({
resolver: zodResolver(plantingDetailsFormSchema),
defaultValues: {},
defaultValues: {
daysToEmerge: 0,
plantSpacing: 0,
rowSpacing: 0,
plantingDepth: 0,
averageHeight: 0,
startMethod: "",
lightProfile: "",
soilConditions: "",
plantingDetails: "",
pruningDetails: "",
isPerennial: false,
autoCreateTasks: false,
},
});
const onSubmit = (data: plantingSchema) => {
onChange(data);
};
return (
<Form {...form}>
<form className="grid grid-cols-3 gap-5">
<form
className="grid grid-cols-3 gap-5"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="daysToEmerge"
@ -47,6 +72,13 @@ export default function PlantingDetailsForm() {
id="daysToEmerge"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -69,6 +101,13 @@ export default function PlantingDetailsForm() {
id="plantSpacing"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -91,6 +130,13 @@ export default function PlantingDetailsForm() {
id="rowSpacing"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -115,6 +161,13 @@ export default function PlantingDetailsForm() {
id="plantingDepth"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -139,6 +192,13 @@ export default function PlantingDetailsForm() {
id="averageHeight"
className="w-96"
{...field}
onChange={(e) => {
// convert to number
const value = e.target.value
? parseInt(e.target.value, 10)
: "";
field.onChange(value);
}}
/>
</div>
</div>
@ -187,9 +247,9 @@ export default function PlantingDetailsForm() {
<SelectValue placeholder="Select light profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="xp">Seed</SelectItem>
<SelectItem value="xa">Transplant</SelectItem>
<SelectItem value="xz">Cutting</SelectItem>
<SelectItem value="Seed">Seed</SelectItem>
<SelectItem value="Transplant">Transplant</SelectItem>
<SelectItem value="Cutting">Cutting</SelectItem>
</SelectContent>
</Select>
</FormControl>
@ -214,9 +274,9 @@ export default function PlantingDetailsForm() {
<SelectValue placeholder="Select a soil condition" />
</SelectTrigger>
<SelectContent>
<SelectItem value="xp">Seed</SelectItem>
<SelectItem value="xa">Transplant</SelectItem>
<SelectItem value="xz">Cutting</SelectItem>
<SelectItem value="Seed">Seed</SelectItem>
<SelectItem value="Transplant">Transplant</SelectItem>
<SelectItem value="Cutting">Cutting</SelectItem>
</SelectContent>
</Select>
</FormControl>
@ -289,7 +349,7 @@ export default function PlantingDetailsForm() {
/>
<FormField
control={form.control}
name="isPerennial"
name="autoCreateTasks"
render={({ field }: { field: any }) => (
<FormItem>
<FormControl>
@ -308,6 +368,9 @@ export default function PlantingDetailsForm() {
</FormItem>
)}
/>
<div className="col-span-3 flex justify-center">
<Button type="submit">Save</Button>
</div>
</form>
</Form>
);

View File

@ -1,5 +1,3 @@
"use client";
import { GoogleMap, LoadScript, DrawingManager } from "@react-google-maps/api";
import { useState, useCallback } from "react";
@ -10,17 +8,80 @@ const containerStyle = {
const center = { lat: 13.7563, lng: 100.5018 }; // Example: Bangkok, Thailand
const GoogleMapWithDrawing = () => {
interface GoogleMapWithDrawingProps {
onAreaSelected: (data: { lat: number; lng: number }[]) => void;
}
const GoogleMapWithDrawing = ({
onAreaSelected,
}: GoogleMapWithDrawingProps) => {
const [map, setMap] = useState<google.maps.Map | null>(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) => {
const shape = overlay.overlay;
// check the shape of the drawing and extract lat/lng values
if (shape instanceof google.maps.Polygon) {
const path = shape.getPath();
const coordinates = path.getArray().map((latLng) => ({
lat: latLng.lat(),
lng: latLng.lng(),
}));
console.log("Polygon coordinates:", coordinates);
onAreaSelected(coordinates);
} else if (shape instanceof google.maps.Rectangle) {
const bounds = shape.getBounds();
if (bounds) {
const northEast = bounds.getNorthEast();
const southWest = bounds.getSouthWest();
const coordinates = [
{ lat: northEast.lat(), lng: northEast.lng() },
{ lat: southWest.lat(), lng: southWest.lng() },
];
console.log("Rectangle coordinates:", coordinates);
onAreaSelected(coordinates);
}
} else if (shape instanceof google.maps.Circle) {
const center = shape.getCenter();
const radius = shape.getRadius();
if (center) {
const coordinates = [
{
lat: center.lat(),
lng: center.lng(),
radius: radius, // circle's radius in meters
},
];
console.log("Circle center:", coordinates);
onAreaSelected(coordinates);
}
} else if (shape instanceof google.maps.Polyline) {
const path = shape.getPath();
const coordinates = path.getArray().map((latLng) => ({
lat: latLng.lat(),
lng: latLng.lng(),
}));
console.log("Polyline coordinates:", coordinates);
onAreaSelected(coordinates);
} else {
console.log("Unknown shape detected:", shape);
}
},
[onAreaSelected]
);
return (
<LoadScript googleMapsApiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!} libraries={["drawing"]}>
<GoogleMap mapContainerStyle={containerStyle} center={center} zoom={10} onLoad={(map) => setMap(map)}>
<LoadScript
googleMapsApiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!}
libraries={["drawing"]}
>
<GoogleMap
mapContainerStyle={containerStyle}
center={center}
zoom={10}
onLoad={(map) => setMap(map)}
>
{map && (
<DrawingManager
onOverlayComplete={onDrawingComplete}

View File

@ -31,6 +31,7 @@
"@react-oauth/google": "^0.12.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -45,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",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",

File diff suppressed because it is too large Load Diff

View File

@ -69,6 +69,10 @@ export type InventoryItem = {
lastUpdated: string;
status: string;
};
export type InventoryItemStatus = {
id: number;
name: string;
};
export type CreateInventoryItemInput = Omit<InventoryItem, "id" | "lastUpdated" | "status">;