Merge branch 'main' into feature-farm-setup

This commit is contained in:
Sosokker 2025-04-01 17:42:49 +07:00
commit f8752a94de
15 changed files with 718 additions and 275 deletions

View File

@ -26,11 +26,12 @@ type api struct {
httpClient *http.Client
eventPublisher domain.EventPublisher
userRepo domain.UserRepository
cropRepo domain.CroplandRepository
farmRepo domain.FarmRepository
plantRepo domain.PlantRepository
userRepo domain.UserRepository
cropRepo domain.CroplandRepository
farmRepo domain.FarmRepository
plantRepo domain.PlantRepository
inventoryRepo domain.InventoryRepository
harvestRepo domain.HarvestRepository
}
func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventPublisher domain.EventPublisher) *api {
@ -42,6 +43,7 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventP
farmRepository := repository.NewPostgresFarm(pool)
plantRepository := repository.NewPostgresPlant(pool)
inventoryRepository := repository.NewPostgresInventory(pool)
harvestRepository := repository.NewPostgresHarvest(pool)
farmRepository.SetEventPublisher(eventPublisher)
@ -50,11 +52,12 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventP
httpClient: client,
eventPublisher: eventPublisher,
userRepo: userRepository,
cropRepo: croplandRepository,
farmRepo: farmRepository,
plantRepo: plantRepository,
userRepo: userRepository,
cropRepo: croplandRepository,
farmRepo: farmRepository,
plantRepo: plantRepository,
inventoryRepo: inventoryRepository,
harvestRepo: harvestRepository,
}
}

View File

@ -22,11 +22,11 @@ func (a *api) registerInventoryRoutes(_ chi.Router, api huma.API) {
}, a.createInventoryItemHandler)
huma.Register(api, huma.Operation{
OperationID: "getInventoryItems",
OperationID: "getInventoryItemsByUser",
Method: http.MethodGet,
Path: prefix,
Tags: tags,
}, a.getInventoryItemsHandler)
}, a.getInventoryItemsByUserHandler)
huma.Register(api, huma.Operation{
OperationID: "getInventoryItem",
@ -48,18 +48,66 @@ func (a *api) registerInventoryRoutes(_ chi.Router, api huma.API) {
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"`
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"`
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"`
}
}
@ -69,15 +117,83 @@ type CreateInventoryItemOutput struct {
}
}
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{
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),
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 {
@ -94,40 +210,11 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven
}{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) {
func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInventoryItemsInput) (*GetInventoryItemsOutput, error) {
filter := domain.InventoryFilter{
Category: input.Category,
Type: input.Type,
Status: domain.InventoryStatus(input.Status),
UserID: input.UserID,
CategoryID: input.CategoryID,
StatusID: input.StatusID,
StartDate: input.StartDate,
EndDate: input.EndDate,
SearchQuery: input.SearchQuery,
@ -138,7 +225,7 @@ func (a *api) getInventoryItemsHandler(ctx context.Context, input *GetInventoryI
Direction: input.SortOrder,
}
items, err := a.inventoryRepo.GetWithFilter(ctx, filter, sort)
items, err := a.inventoryRepo.GetByUserID(ctx, input.UserID, filter, sort)
if err != nil {
return nil, err
}
@ -146,14 +233,22 @@ func (a *api) getInventoryItemsHandler(ctx context.Context, input *GetInventoryI
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,
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: string(item.Status),
Status: InventoryStatus{
ID: item.Status.ID,
Name: item.Status.Name,
},
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
@ -162,55 +257,36 @@ func (a *api) getInventoryItemsHandler(ctx context.Context, input *GetInventoryI
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)
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: item.Category,
Type: item.Type,
Quantity: item.Quantity,
Unit: item.Unit,
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: string(item.Status),
Status: InventoryStatus{
ID: item.Status.ID,
Name: item.Status.Name,
},
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)
item, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID)
if err != nil {
return nil, err
}
@ -218,23 +294,20 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven
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.CategoryID != 0 {
item.CategoryID = input.Body.CategoryID
}
if input.Body.Quantity != 0 {
item.Quantity = input.Body.Quantity
}
if input.Body.Unit != "" {
item.Unit = input.Body.Unit
if input.Body.UnitID != 0 {
item.UnitID = input.Body.UnitID
}
if !input.Body.DateAdded.IsZero() {
item.DateAdded = input.Body.DateAdded
}
if input.Body.Status != "" {
item.Status = domain.InventoryStatus(input.Body.Status)
if input.Body.StatusID != 0 {
item.StatusID = input.Body.StatusID
}
if err := item.Validate(); err != nil {
@ -246,38 +319,35 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven
return nil, err
}
updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID)
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: updatedItem.Category,
Type: updatedItem.Type,
Quantity: updatedItem.Quantity,
Unit: updatedItem.Unit,
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: string(updatedItem.Status),
Status: InventoryStatus{
ID: updatedItem.Status.ID,
Name: updatedItem.Status.Name,
},
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)
err := a.inventoryRepo.Delete(ctx, input.ID, input.UserID)
if err != nil {
return nil, err
}
@ -286,3 +356,54 @@ func (a *api) deleteInventoryItemHandler(ctx context.Context, input *DeleteInven
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

@ -7,31 +7,41 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
)
type InventoryStatus string
type InventoryStatus struct {
ID int `json:"id"`
Name string `json:"name"`
}
const (
StatusInStock InventoryStatus = "In Stock"
StatusLowStock InventoryStatus = "Low Stock"
StatusOutOfStock InventoryStatus = "Out of Stock"
)
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
Name string
Category string
Type string
Quantity float64
Unit string
DateAdded time.Time
Status InventoryStatus
CreatedAt time.Time
UpdatedAt time.Time
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 {
Category string
Type string
Status InventoryStatus
UserID string
CategoryID int
StatusID int
StartDate time.Time
EndDate time.Time
SearchQuery string
@ -44,18 +54,26 @@ type InventorySort struct {
func (i *InventoryItem) Validate() error {
return validation.ValidateStruct(i,
validation.Field(&i.UserID, validation.Required),
validation.Field(&i.Name, validation.Required),
validation.Field(&i.Category, validation.Required),
validation.Field(&i.Type, validation.Required),
validation.Field(&i.CategoryID, 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)),
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 string) (InventoryItem, error)
GetWithFilter(ctx context.Context, filter InventoryFilter, sort InventorySort) ([]InventoryItem, error)
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 string) 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

@ -29,13 +29,13 @@ func (p *postgresInventoryRepository) fetch(ctx context.Context, query string, a
var i domain.InventoryItem
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Category,
&i.Type,
&i.CategoryID,
&i.Quantity,
&i.Unit,
&i.UnitID,
&i.DateAdded,
&i.Status,
&i.StatusID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@ -46,80 +46,115 @@ func (p *postgresInventoryRepository) fetch(ctx context.Context, query string, a
return items, nil
}
func (p *postgresInventoryRepository) GetByID(ctx context.Context, id string) (domain.InventoryItem, error) {
func (p *postgresInventoryRepository) GetByID(ctx context.Context, id, userID 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`
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`
items, err := p.fetch(ctx, query, id)
rows, err := p.conn.Query(ctx, query, id, userID)
if err != nil {
return domain.InventoryItem{}, err
}
if len(items) == 0 {
defer rows.Close()
if !rows.Next() {
return domain.InventoryItem{}, domain.ErrNotFound
}
return items[0], nil
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) GetWithFilter(ctx context.Context, filter domain.InventoryFilter, sort domain.InventorySort) ([]domain.InventoryItem, error) {
func (p *postgresInventoryRepository) GetByUserID(
ctx context.Context,
userID string,
filter domain.InventoryFilter,
sort domain.InventorySort,
) ([]domain.InventoryItem, error) {
var query strings.Builder
args := []interface{}{}
argPos := 1
args := []interface{}{userID}
argPos := 2
query.WriteString(`
SELECT id, name, category, type, quantity, unit, date_added, status, created_at, updated_at
FROM inventory_items
WHERE 1=1`)
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.Category != "" {
query.WriteString(fmt.Sprintf(" AND category = $%d", argPos))
args = append(args, filter.Category)
if filter.CategoryID != 0 {
query.WriteString(fmt.Sprintf(" AND i.category_id = $%d", argPos))
args = append(args, filter.CategoryID)
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)
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 date_added >= $%d", argPos))
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 date_added <= $%d", argPos))
query.WriteString(fmt.Sprintf(" AND i.date_added <= $%d", argPos))
args = append(args, filter.EndDate)
argPos++
}
if filter.SearchQuery != "" {
query.WriteString(fmt.Sprintf(" AND name ILIKE $%d", argPos))
query.WriteString(fmt.Sprintf(" AND i.name ILIKE $%d", argPos))
args = append(args, "%"+filter.SearchQuery+"%")
argPos++
}
if sort.Field == "" {
sort.Field = "date_added"
sort.Field = "i.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,
}
@ -132,7 +167,53 @@ func (p *postgresInventoryRepository) GetWithFilter(ctx context.Context, filter
}
}
return p.fetch(ctx, query.String(), args...)
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 {
@ -143,19 +224,19 @@ func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *
item.CreatedAt = now
query := `
INSERT INTO inventory_items
(id, name, category, type, quantity, unit, date_added, status, created_at, updated_at)
(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.Category,
item.Type,
item.CategoryID,
item.Quantity,
item.Unit,
item.UnitID,
item.DateAdded,
item.Status,
item.StatusID,
item.CreatedAt,
item.UpdatedAt,
).Scan(&item.ID)
@ -164,33 +245,70 @@ func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *
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
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.Category,
item.Type,
item.CategoryID,
item.Quantity,
item.Unit,
item.UnitID,
item.DateAdded,
item.Status,
item.StatusID,
item.UpdatedAt,
item.ID,
item.UserID,
).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)
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

@ -1,6 +1,7 @@
-- +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,
@ -9,8 +10,11 @@ CREATE TABLE inventory_items (
date_added TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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 INDEX idx_inventory_items_category ON inventory_items(category);
CREATE INDEX idx_inventory_items_status ON inventory_items(status);
-- 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,

View File

@ -36,14 +36,32 @@ import { cn } from "@/lib/utils";
// import { updateInventoryItem } from "@/api/inventory";
// import type { UpdateInventoryItemInput } from "@/types";
export function EditInventoryItem() {
const [date, setDate] = useState<Date | undefined>();
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("");
const [itemType, setItemType] = useState("");
const [itemCategory, setItemCategory] = useState("");
const [itemQuantity, setItemQuantity] = useState(0);
const [itemUnit, setItemUnit] = useState("");
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();
@ -103,7 +121,7 @@ export function EditInventoryItem() {
<Label htmlFor="type" className="text-right">
Type
</Label>
<Select value={itemType} onValueChange={setItemType}>
<Select value={itemType.toLowerCase()} onValueChange={setItemType}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select type" />
</SelectTrigger>
@ -116,6 +134,27 @@ export function EditInventoryItem() {
</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
@ -152,33 +191,6 @@ export function EditInventoryItem() {
onChange={(e) => setItemUnit(e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="date" className="text-right">
Date
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"col-span-3 justify-start text-left font-normal",
!date && "text-muted-foreground",
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP") : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={handleEdit}>

View File

@ -1,13 +1,6 @@
"use client";
import {
useState,
JSXElementConstructor,
ReactElement,
ReactNode,
ReactPortal,
useMemo,
} from "react";
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
useReactTable,
@ -38,9 +31,12 @@ 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 { fetchInventoryItems, fetchInventoryStatus } from "@/api/inventory";
import { AddInventoryItem } from "./add-inventory-item";
import { EditInventoryItem } from "./edit-inventory-item";
import {
EditInventoryItem,
EditInventoryItemProps,
} from "./edit-inventory-item";
import { DeleteInventoryItem } from "./delete-inventory-item";
export default function InventoryPage() {
@ -52,26 +48,42 @@ export default function InventoryPage() {
const {
data: inventoryItems = [],
isLoading,
isError,
isLoading: isItemLoading,
isError: isItemError,
} = useQuery({
queryKey: ["inventoryItems"],
queryFn: fetchInventoryItems,
staleTime: 60 * 1000,
});
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.filter((item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return inventoryItems
.map((item) => ({
...item,
id: String(item.id), // Convert `id` to string here
}))
.filter((item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [inventoryItems, searchTerm]);
const columns = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "category", header: "Category" },
{ accessorKey: "type", header: "Type" },
{ accessorKey: "quantity", header: "Quantity" },
{ accessorKey: "unit", header: "Unit" },
{ accessorKey: "lastUpdated", header: "Last Updated" },
{
accessorKey: "status",
@ -83,7 +95,7 @@ export default function InventoryPage() {
if (status === "Low Stock") {
statusClass = "bg-yellow-300"; // yellow for low stock
} else if (status === "Out of Stock") {
} else if (status === "Out Of Stock") {
statusClass = "bg-red-500 text-white"; // red for out of stock
}
@ -97,7 +109,9 @@ export default function InventoryPage() {
{
accessorKey: "edit",
header: "Edit",
cell: () => <EditInventoryItem />,
cell: ({ row }: { row: { original: EditInventoryItemProps } }) => (
<EditInventoryItem {...row.original} />
),
enableSorting: false,
},
{
@ -119,13 +133,13 @@ export default function InventoryPage() {
onPaginationChange: setPagination,
});
if (isLoading)
if (isItemLoading || isLoadingStatus)
return (
<div className="flex min-h-screen items-center justify-center">
Loading...
</div>
);
if (isError)
if (isItemError || isErrorStatus)
return (
<div className="flex min-h-screen items-center justify-center">
Error loading inventory data.
@ -183,7 +197,7 @@ export default function InventoryPage() {
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
<TableRow key={row.id} className="even:bg-gray-800">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(

View File

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