mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 22:14:08 +01:00
Merge branch 'feature-farm-setup' of https://github.com/ForFarmTeam/ForFarm into feature-farm-setup
This commit is contained in:
commit
d6dc91270e
@ -30,6 +30,7 @@ type api struct {
|
|||||||
cropRepo domain.CroplandRepository
|
cropRepo domain.CroplandRepository
|
||||||
farmRepo domain.FarmRepository
|
farmRepo domain.FarmRepository
|
||||||
plantRepo domain.PlantRepository
|
plantRepo domain.PlantRepository
|
||||||
|
inventoryRepo domain.InventoryRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventPublisher domain.EventPublisher) *api {
|
func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventPublisher domain.EventPublisher) *api {
|
||||||
@ -40,6 +41,7 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventP
|
|||||||
croplandRepository := repository.NewPostgresCropland(pool)
|
croplandRepository := repository.NewPostgresCropland(pool)
|
||||||
farmRepository := repository.NewPostgresFarm(pool)
|
farmRepository := repository.NewPostgresFarm(pool)
|
||||||
plantRepository := repository.NewPostgresPlant(pool)
|
plantRepository := repository.NewPostgresPlant(pool)
|
||||||
|
inventoryRepository := repository.NewPostgresInventory(pool)
|
||||||
|
|
||||||
farmRepository.SetEventPublisher(eventPublisher)
|
farmRepository.SetEventPublisher(eventPublisher)
|
||||||
|
|
||||||
@ -52,6 +54,7 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool, eventP
|
|||||||
cropRepo: croplandRepository,
|
cropRepo: croplandRepository,
|
||||||
farmRepo: farmRepository,
|
farmRepo: farmRepository,
|
||||||
plantRepo: plantRepository,
|
plantRepo: plantRepository,
|
||||||
|
inventoryRepo: inventoryRepository,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +103,7 @@ func (a *api) Routes() *chi.Mux {
|
|||||||
a.registerHelloRoutes(r, api)
|
a.registerHelloRoutes(r, api)
|
||||||
a.registerFarmRoutes(r, api)
|
a.registerFarmRoutes(r, api)
|
||||||
a.registerUserRoutes(r, api)
|
a.registerUserRoutes(r, api)
|
||||||
|
a.registerInventoryRoutes(r, api)
|
||||||
})
|
})
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
288
backend/internal/api/inventory.go
Normal file
288
backend/internal/api/inventory.go
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/forfarm/backend/internal/domain"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *api) registerInventoryRoutes(_ chi.Router, api huma.API) {
|
||||||
|
tags := []string{"inventory"}
|
||||||
|
prefix := "/inventory"
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "createInventoryItem",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: prefix,
|
||||||
|
Tags: tags,
|
||||||
|
}, a.createInventoryItemHandler)
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "getInventoryItems",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: prefix,
|
||||||
|
Tags: tags,
|
||||||
|
}, a.getInventoryItemsHandler)
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "getInventoryItem",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: prefix + "/{id}",
|
||||||
|
Tags: tags,
|
||||||
|
}, a.getInventoryItemHandler)
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "updateInventoryItem",
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: prefix + "/{id}",
|
||||||
|
Tags: tags,
|
||||||
|
}, a.updateInventoryItemHandler)
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "deleteInventoryItem",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: prefix + "/{id}",
|
||||||
|
Tags: tags,
|
||||||
|
}, a.deleteInventoryItemHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInventoryItemInput struct {
|
||||||
|
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||||
|
Body struct {
|
||||||
|
Name string `json:"name" required:"true"`
|
||||||
|
Category string `json:"category" required:"true"`
|
||||||
|
Type string `json:"type" required:"true"`
|
||||||
|
Quantity float64 `json:"quantity" required:"true"`
|
||||||
|
Unit string `json:"unit" required:"true"`
|
||||||
|
DateAdded time.Time `json:"date_added" required:"true"`
|
||||||
|
Status string `json:"status" required:"true" enum:"In Stock,Low Stock,Out of Stock"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInventoryItemOutput struct {
|
||||||
|
Body struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInventoryItemInput) (*CreateInventoryItemOutput, error) {
|
||||||
|
item := &domain.InventoryItem{
|
||||||
|
Name: input.Body.Name,
|
||||||
|
Category: input.Body.Category,
|
||||||
|
Type: input.Body.Type,
|
||||||
|
Quantity: input.Body.Quantity,
|
||||||
|
Unit: input.Body.Unit,
|
||||||
|
DateAdded: input.Body.DateAdded,
|
||||||
|
Status: domain.InventoryStatus(input.Body.Status),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := item.Validate(); err != nil {
|
||||||
|
return nil, huma.Error422UnprocessableEntity(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.inventoryRepo.CreateOrUpdate(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CreateInventoryItemOutput{Body: struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}{ID: item.ID}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetInventoryItemsInput struct {
|
||||||
|
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||||
|
Category string `query:"category"`
|
||||||
|
Type string `query:"type"`
|
||||||
|
Status string `query:"status" enum:"In Stock,Low Stock,Out of Stock"`
|
||||||
|
StartDate time.Time `query:"start_date" format:"date-time"`
|
||||||
|
EndDate time.Time `query:"end_date" format:"date-time"`
|
||||||
|
SearchQuery string `query:"search"`
|
||||||
|
SortBy string `query:"sort_by" enum:"name,category,type,quantity,date_added,status,created_at"`
|
||||||
|
SortOrder string `query:"sort_order" enum:"asc,desc" default:"desc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InventoryItemResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
DateAdded time.Time `json:"date_added"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetInventoryItemsOutput struct {
|
||||||
|
Body []InventoryItemResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) getInventoryItemsHandler(ctx context.Context, input *GetInventoryItemsInput) (*GetInventoryItemsOutput, error) {
|
||||||
|
filter := domain.InventoryFilter{
|
||||||
|
Category: input.Category,
|
||||||
|
Type: input.Type,
|
||||||
|
Status: domain.InventoryStatus(input.Status),
|
||||||
|
StartDate: input.StartDate,
|
||||||
|
EndDate: input.EndDate,
|
||||||
|
SearchQuery: input.SearchQuery,
|
||||||
|
}
|
||||||
|
|
||||||
|
sort := domain.InventorySort{
|
||||||
|
Field: input.SortBy,
|
||||||
|
Direction: input.SortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := a.inventoryRepo.GetWithFilter(ctx, filter, sort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]InventoryItemResponse, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
response[i] = InventoryItemResponse{
|
||||||
|
ID: item.ID,
|
||||||
|
Name: item.Name,
|
||||||
|
Category: item.Category,
|
||||||
|
Type: item.Type,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Unit: item.Unit,
|
||||||
|
DateAdded: item.DateAdded,
|
||||||
|
Status: string(item.Status),
|
||||||
|
CreatedAt: item.CreatedAt,
|
||||||
|
UpdatedAt: item.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GetInventoryItemsOutput{Body: response}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetInventoryItemInput struct {
|
||||||
|
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||||
|
ID string `path:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetInventoryItemOutput struct {
|
||||||
|
Body InventoryItemResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) getInventoryItemHandler(ctx context.Context, input *GetInventoryItemInput) (*GetInventoryItemOutput, error) {
|
||||||
|
item, err := a.inventoryRepo.GetByID(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GetInventoryItemOutput{Body: InventoryItemResponse{
|
||||||
|
ID: item.ID,
|
||||||
|
Name: item.Name,
|
||||||
|
Category: item.Category,
|
||||||
|
Type: item.Type,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Unit: item.Unit,
|
||||||
|
DateAdded: item.DateAdded,
|
||||||
|
Status: string(item.Status),
|
||||||
|
CreatedAt: item.CreatedAt,
|
||||||
|
UpdatedAt: item.UpdatedAt,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateInventoryItemInput struct {
|
||||||
|
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||||
|
ID string `path:"id"`
|
||||||
|
Body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
DateAdded time.Time `json:"date_added"`
|
||||||
|
Status string `json:"status" enum:"In Stock,Low Stock,Out of Stock"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateInventoryItemOutput struct {
|
||||||
|
Body InventoryItemResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInventoryItemInput) (*UpdateInventoryItemOutput, error) {
|
||||||
|
item, err := a.inventoryRepo.GetByID(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Body.Name != "" {
|
||||||
|
item.Name = input.Body.Name
|
||||||
|
}
|
||||||
|
if input.Body.Category != "" {
|
||||||
|
item.Category = input.Body.Category
|
||||||
|
}
|
||||||
|
if input.Body.Type != "" {
|
||||||
|
item.Type = input.Body.Type
|
||||||
|
}
|
||||||
|
if input.Body.Quantity != 0 {
|
||||||
|
item.Quantity = input.Body.Quantity
|
||||||
|
}
|
||||||
|
if input.Body.Unit != "" {
|
||||||
|
item.Unit = input.Body.Unit
|
||||||
|
}
|
||||||
|
if !input.Body.DateAdded.IsZero() {
|
||||||
|
item.DateAdded = input.Body.DateAdded
|
||||||
|
}
|
||||||
|
if input.Body.Status != "" {
|
||||||
|
item.Status = domain.InventoryStatus(input.Body.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := item.Validate(); err != nil {
|
||||||
|
return nil, huma.Error422UnprocessableEntity(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.inventoryRepo.CreateOrUpdate(ctx, &item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UpdateInventoryItemOutput{Body: InventoryItemResponse{
|
||||||
|
ID: updatedItem.ID,
|
||||||
|
Name: updatedItem.Name,
|
||||||
|
Category: updatedItem.Category,
|
||||||
|
Type: updatedItem.Type,
|
||||||
|
Quantity: updatedItem.Quantity,
|
||||||
|
Unit: updatedItem.Unit,
|
||||||
|
DateAdded: updatedItem.DateAdded,
|
||||||
|
Status: string(updatedItem.Status),
|
||||||
|
CreatedAt: updatedItem.CreatedAt,
|
||||||
|
UpdatedAt: updatedItem.UpdatedAt,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteInventoryItemInput struct {
|
||||||
|
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||||
|
ID string `path:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteInventoryItemOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) deleteInventoryItemHandler(ctx context.Context, input *DeleteInventoryItemInput) (*DeleteInventoryItemOutput, error) {
|
||||||
|
err := a.inventoryRepo.Delete(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DeleteInventoryItemOutput{Body: struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}{Message: "Inventory item deleted successfully"}}, nil
|
||||||
|
}
|
||||||
61
backend/internal/domain/inventory.go
Normal file
61
backend/internal/domain/inventory.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InventoryStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusInStock InventoryStatus = "In Stock"
|
||||||
|
StatusLowStock InventoryStatus = "Low Stock"
|
||||||
|
StatusOutOfStock InventoryStatus = "Out of Stock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InventoryItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Category string
|
||||||
|
Type string
|
||||||
|
Quantity float64
|
||||||
|
Unit string
|
||||||
|
DateAdded time.Time
|
||||||
|
Status InventoryStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type InventoryFilter struct {
|
||||||
|
Category string
|
||||||
|
Type string
|
||||||
|
Status InventoryStatus
|
||||||
|
StartDate time.Time
|
||||||
|
EndDate time.Time
|
||||||
|
SearchQuery string
|
||||||
|
}
|
||||||
|
|
||||||
|
type InventorySort struct {
|
||||||
|
Field string
|
||||||
|
Direction string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *InventoryItem) Validate() error {
|
||||||
|
return validation.ValidateStruct(i,
|
||||||
|
validation.Field(&i.Name, validation.Required),
|
||||||
|
validation.Field(&i.Category, validation.Required),
|
||||||
|
validation.Field(&i.Type, validation.Required),
|
||||||
|
validation.Field(&i.Quantity, validation.Required, validation.Min(0.0)),
|
||||||
|
validation.Field(&i.Unit, validation.Required),
|
||||||
|
validation.Field(&i.Status, validation.Required, validation.In(StatusInStock, StatusLowStock, StatusOutOfStock)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type InventoryRepository interface {
|
||||||
|
GetByID(ctx context.Context, id string) (InventoryItem, error)
|
||||||
|
GetWithFilter(ctx context.Context, filter InventoryFilter, sort InventorySort) ([]InventoryItem, error)
|
||||||
|
CreateOrUpdate(ctx context.Context, item *InventoryItem) error
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
}
|
||||||
196
backend/internal/repository/postgres_inventory.go
Normal file
196
backend/internal/repository/postgres_inventory.go
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/forfarm/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type postgresInventoryRepository struct {
|
||||||
|
conn Connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPostgresInventory(conn Connection) domain.InventoryRepository {
|
||||||
|
return &postgresInventoryRepository{conn: conn}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *postgresInventoryRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.InventoryItem, error) {
|
||||||
|
rows, err := p.conn.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []domain.InventoryItem
|
||||||
|
for rows.Next() {
|
||||||
|
var i domain.InventoryItem
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Category,
|
||||||
|
&i.Type,
|
||||||
|
&i.Quantity,
|
||||||
|
&i.Unit,
|
||||||
|
&i.DateAdded,
|
||||||
|
&i.Status,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *postgresInventoryRepository) GetByID(ctx context.Context, id string) (domain.InventoryItem, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, category, type, quantity, unit, date_added, status, created_at, updated_at
|
||||||
|
FROM inventory_items
|
||||||
|
WHERE id = $1`
|
||||||
|
|
||||||
|
items, err := p.fetch(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return domain.InventoryItem{}, err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return domain.InventoryItem{}, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return items[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *postgresInventoryRepository) GetWithFilter(ctx context.Context, filter domain.InventoryFilter, sort domain.InventorySort) ([]domain.InventoryItem, error) {
|
||||||
|
var query strings.Builder
|
||||||
|
args := []interface{}{}
|
||||||
|
argPos := 1
|
||||||
|
|
||||||
|
query.WriteString(`
|
||||||
|
SELECT id, name, category, type, quantity, unit, date_added, status, created_at, updated_at
|
||||||
|
FROM inventory_items
|
||||||
|
WHERE 1=1`)
|
||||||
|
|
||||||
|
if filter.Category != "" {
|
||||||
|
query.WriteString(fmt.Sprintf(" AND category = $%d", argPos))
|
||||||
|
args = append(args, filter.Category)
|
||||||
|
argPos++
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Type != "" {
|
||||||
|
query.WriteString(fmt.Sprintf(" AND type = $%d", argPos))
|
||||||
|
args = append(args, filter.Type)
|
||||||
|
argPos++
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Status != "" {
|
||||||
|
query.WriteString(fmt.Sprintf(" AND status = $%d", argPos))
|
||||||
|
args = append(args, filter.Status)
|
||||||
|
argPos++
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filter.StartDate.IsZero() {
|
||||||
|
query.WriteString(fmt.Sprintf(" AND date_added >= $%d", argPos))
|
||||||
|
args = append(args, filter.StartDate)
|
||||||
|
argPos++
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filter.EndDate.IsZero() {
|
||||||
|
query.WriteString(fmt.Sprintf(" AND date_added <= $%d", argPos))
|
||||||
|
args = append(args, filter.EndDate)
|
||||||
|
argPos++
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.SearchQuery != "" {
|
||||||
|
query.WriteString(fmt.Sprintf(" AND name ILIKE $%d", argPos))
|
||||||
|
args = append(args, "%"+filter.SearchQuery+"%")
|
||||||
|
argPos++
|
||||||
|
}
|
||||||
|
|
||||||
|
if sort.Field == "" {
|
||||||
|
sort.Field = "date_added"
|
||||||
|
sort.Direction = "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
validSortFields := map[string]bool{
|
||||||
|
"name": true,
|
||||||
|
"category": true,
|
||||||
|
"type": true,
|
||||||
|
"quantity": true,
|
||||||
|
"date_added": true,
|
||||||
|
"status": true,
|
||||||
|
"created_at": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if validSortFields[sort.Field] {
|
||||||
|
query.WriteString(fmt.Sprintf(" ORDER BY %s", sort.Field))
|
||||||
|
if strings.ToLower(sort.Direction) == "desc" {
|
||||||
|
query.WriteString(" DESC")
|
||||||
|
} else {
|
||||||
|
query.WriteString(" ASC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.fetch(ctx, query.String(), args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *domain.InventoryItem) error {
|
||||||
|
now := time.Now()
|
||||||
|
item.UpdatedAt = now
|
||||||
|
|
||||||
|
if item.ID == "" {
|
||||||
|
item.CreatedAt = now
|
||||||
|
query := `
|
||||||
|
INSERT INTO inventory_items
|
||||||
|
(id, name, category, type, quantity, unit, date_added, status, created_at, updated_at)
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id`
|
||||||
|
return p.conn.QueryRow(
|
||||||
|
ctx,
|
||||||
|
query,
|
||||||
|
item.Name,
|
||||||
|
item.Category,
|
||||||
|
item.Type,
|
||||||
|
item.Quantity,
|
||||||
|
item.Unit,
|
||||||
|
item.DateAdded,
|
||||||
|
item.Status,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.UpdatedAt,
|
||||||
|
).Scan(&item.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE inventory_items
|
||||||
|
SET name = $1,
|
||||||
|
category = $2,
|
||||||
|
type = $3,
|
||||||
|
quantity = $4,
|
||||||
|
unit = $5,
|
||||||
|
date_added = $6,
|
||||||
|
status = $7,
|
||||||
|
updated_at = $8
|
||||||
|
WHERE id = $9
|
||||||
|
RETURNING id`
|
||||||
|
|
||||||
|
return p.conn.QueryRow(
|
||||||
|
ctx,
|
||||||
|
query,
|
||||||
|
item.Name,
|
||||||
|
item.Category,
|
||||||
|
item.Type,
|
||||||
|
item.Quantity,
|
||||||
|
item.Unit,
|
||||||
|
item.DateAdded,
|
||||||
|
item.Status,
|
||||||
|
item.UpdatedAt,
|
||||||
|
item.ID,
|
||||||
|
).Scan(&item.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *postgresInventoryRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
query := `DELETE FROM inventory_items WHERE id = $1`
|
||||||
|
_, err := p.conn.Exec(ctx, query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
16
backend/migrations/00004_create_inventory_items_table.sql
Normal file
16
backend/migrations/00004_create_inventory_items_table.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE inventory_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
quantity DOUBLE PRECISION NOT NULL,
|
||||||
|
unit TEXT NOT NULL,
|
||||||
|
date_added TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_inventory_items_category ON inventory_items(category);
|
||||||
|
CREATE INDEX idx_inventory_items_status ON inventory_items(status);
|
||||||
@ -79,7 +79,10 @@ export async function createInventoryItem(
|
|||||||
// Simulate network delay
|
// Simulate network delay
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.post<InventoryItem>("/api/inventory", item);
|
const response = await axiosInstance.post<InventoryItem>(
|
||||||
|
"/api/inventory",
|
||||||
|
item
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Simulate successful creation if API endpoint is not available
|
// Simulate successful creation if API endpoint is not available
|
||||||
|
|||||||
@ -18,7 +18,13 @@ import {
|
|||||||
CloudRain,
|
CloudRain,
|
||||||
Wind,
|
Wind,
|
||||||
} from "lucide-react";
|
} 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 { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
@ -26,7 +32,11 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { ChatbotDialog } from "./chatbot-dialog";
|
import { ChatbotDialog } from "./chatbot-dialog";
|
||||||
import { AnalyticsDialog } from "./analytics-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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import type { Crop, CropAnalytics } from "@/types";
|
import type { Crop, CropAnalytics } from "@/types";
|
||||||
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
||||||
@ -37,7 +47,11 @@ interface CropDetailPageParams {
|
|||||||
cropId: string;
|
cropId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CropDetailPage({ params }: { params: Promise<CropDetailPageParams> }) {
|
export default function CropDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<CropDetailPageParams>;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [crop, setCrop] = useState<Crop | null>(null);
|
const [crop, setCrop] = useState<Crop | null>(null);
|
||||||
const [analytics, setAnalytics] = useState<CropAnalytics | null>(null);
|
const [analytics, setAnalytics] = useState<CropAnalytics | null>(null);
|
||||||
@ -57,7 +71,9 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
|
|
||||||
if (!crop || !analytics) {
|
if (!crop || !analytics) {
|
||||||
return (
|
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,
|
icon: ListCollapse,
|
||||||
description: "View detailed information",
|
description: "View detailed information",
|
||||||
onClick: () => console.log("Details clicked"),
|
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",
|
title: "Settings",
|
||||||
@ -107,7 +124,8 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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"
|
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
|
<ArrowLeft className="h-4 w-4" /> Back to Farm
|
||||||
</Button>
|
</Button>
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
@ -126,7 +144,9 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="text-sm font-semibold">Growth Timeline</h4>
|
<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">
|
<div className="flex items-center pt-2">
|
||||||
<Separator className="w-full" />
|
<Separator className="w-full" />
|
||||||
<span className="mx-2 text-xs text-muted-foreground">
|
<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 items-center gap-4">
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<div className="flex items-center gap-2">
|
<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}%
|
Health Score: {crop.healthScore}%
|
||||||
</Badge>
|
</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
|
Growing
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{crop.expectedHarvest ? (
|
{crop.expectedHarvest ? (
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Expected harvest: {crop.expectedHarvest.toLocaleDateString()}
|
Expected harvest:{" "}
|
||||||
|
{crop.expectedHarvest.toLocaleDateString()}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@ -180,13 +209,18 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
key={action.title}
|
key={action.title}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105`}
|
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105`}
|
||||||
onClick={action.onClick}>
|
onClick={action.onClick}
|
||||||
<div className={`p-3 rounded-lg ${action.color} group-hover:scale-110 transition-transform`}>
|
>
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-lg ${action.color} group-hover:scale-110 transition-transform`}
|
||||||
|
>
|
||||||
<action.icon className="h-5 w-5" />
|
<action.icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-medium mb-1">{action.title}</div>
|
<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>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@ -196,7 +230,9 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
<Card className="border-green-100 dark:border-green-700">
|
<Card className="border-green-100 dark:border-green-700">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Environmental Conditions</CardTitle>
|
<CardTitle>Environmental Conditions</CardTitle>
|
||||||
<CardDescription>Real-time monitoring of growing conditions</CardDescription>
|
<CardDescription>
|
||||||
|
Real-time monitoring of growing conditions
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
@ -247,15 +283,22 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
].map((metric) => (
|
].map((metric) => (
|
||||||
<Card
|
<Card
|
||||||
key={metric.label}
|
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">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`p-2 rounded-lg ${metric.bg}`}>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">{metric.label}</p>
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
<p className="text-2xl font-semibold tracking-tight">{metric.value}</p>
|
{metric.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold tracking-tight">
|
||||||
|
{metric.value}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -269,9 +312,14 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="font-medium">Growth Progress</span>
|
<span className="font-medium">Growth Progress</span>
|
||||||
<span className="text-muted-foreground">{analytics.growthProgress}%</span>
|
<span className="text-muted-foreground">
|
||||||
|
{analytics.growthProgress}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={analytics.growthProgress} className="h-2" />
|
<Progress
|
||||||
|
value={analytics.growthProgress}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Action Card */}
|
{/* 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" />
|
<Timer className="h-4 w-4 text-green-600 dark:text-green-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium mb-1">Next Action Required</p>
|
<p className="font-medium mb-1">
|
||||||
<p className="text-sm text-muted-foreground">{analytics.nextAction}</p>
|
Next Action Required
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{analytics.nextAction}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Due by {analytics.nextActionDue.toLocaleDateString()}
|
Due by{" "}
|
||||||
|
{analytics.nextActionDue.toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -337,9 +390,14 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
<div key={nutrient.name} className="space-y-2">
|
<div key={nutrient.name} className="space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="font-medium">{nutrient.name}</span>
|
<span className="font-medium">{nutrient.name}</span>
|
||||||
<span className="text-muted-foreground">{nutrient.value}%</span>
|
<span className="text-muted-foreground">
|
||||||
|
{nutrient.value}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={nutrient.value} className={`h-2 ${nutrient.color}`} />
|
<Progress
|
||||||
|
value={nutrient.value}
|
||||||
|
className={`h-2 ${nutrient.color}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -372,10 +430,14 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
][i]
|
][i]
|
||||||
}
|
}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{i < 4 && <Separator className="my-4 dark:bg-slate-700" />}
|
{i < 4 && (
|
||||||
|
<Separator className="my-4 dark:bg-slate-700" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@ -385,8 +447,17 @@ export default function CropDetailPage({ params }: { params: Promise<CropDetailP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dialogs */}
|
{/* Dialogs */}
|
||||||
<ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={crop.name} />
|
<ChatbotDialog
|
||||||
<AnalyticsDialog open={isAnalyticsOpen} onOpenChange={setIsAnalyticsOpen} crop={crop} analytics={analytics} />
|
open={isChatOpen}
|
||||||
|
onOpenChange={setIsChatOpen}
|
||||||
|
cropName={crop.name}
|
||||||
|
/>
|
||||||
|
<AnalyticsDialog
|
||||||
|
open={isAnalyticsOpen}
|
||||||
|
onOpenChange={setIsAnalyticsOpen}
|
||||||
|
crop={crop}
|
||||||
|
analytics={analytics}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -399,9 +470,15 @@ function Activity({ icon }: { icon: number }) {
|
|||||||
const icons = [
|
const icons = [
|
||||||
<Droplets key="0" className="h-4 w-4 text-blue-500 dark:text-blue-300" />,
|
<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" />,
|
<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" />,
|
<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];
|
return icons[icon];
|
||||||
}
|
}
|
||||||
|
|||||||
63
frontend/app/(sidebar)/inventory/delete-inventory-item.tsx
Normal file
63
frontend/app/(sidebar)/inventory/delete-inventory-item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
frontend/app/(sidebar)/inventory/edit-inventory-item.tsx
Normal file
191
frontend/app/(sidebar)/inventory/edit-inventory-item.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CalendarIcon } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
// import { updateInventoryItem } from "@/api/inventory";
|
||||||
|
// import type { UpdateInventoryItemInput } from "@/types";
|
||||||
|
|
||||||
|
export function EditInventoryItem() {
|
||||||
|
const [date, setDate] = useState<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 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} 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="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 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}>
|
||||||
|
Edit Item
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,29 +1,57 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import {
|
||||||
|
useState,
|
||||||
|
JSXElementConstructor,
|
||||||
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
|
ReactPortal,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import {
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
Table,
|
||||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink } from "@/components/ui/pagination";
|
TableBody,
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
TableCell,
|
||||||
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
TableHead,
|
||||||
import { Badge } from "@/components/ui/badge";
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { fetchInventoryItems } from "@/api/inventory";
|
import { fetchInventoryItems } from "@/api/inventory";
|
||||||
import { AddInventoryItem } from "./add-inventory-item";
|
import { AddInventoryItem } from "./add-inventory-item";
|
||||||
|
import { EditInventoryItem } from "./edit-inventory-item";
|
||||||
|
import { DeleteInventoryItem } from "./delete-inventory-item";
|
||||||
|
|
||||||
export default function InventoryPage() {
|
export default function InventoryPage() {
|
||||||
const [date, setDate] = useState<Date>();
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [inventoryType, setInventoryType] = useState("all");
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch inventory items using react-query.
|
|
||||||
const {
|
const {
|
||||||
data: inventoryItems,
|
data: inventoryItems = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
@ -31,23 +59,77 @@ export default function InventoryPage() {
|
|||||||
queryFn: fetchInventoryItems,
|
queryFn: fetchInventoryItems,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
// console.table(inventoryItems);
|
||||||
if (isLoading) {
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
return <div className="flex min-h-screen bg-background items-center justify-center">Loading...</div>;
|
const filteredItems = useMemo(() => {
|
||||||
}
|
return inventoryItems.filter((item) =>
|
||||||
|
item.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
if (isError || !inventoryItems) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen bg-background items-center justify-center">Error loading inventory data.</div>
|
|
||||||
);
|
);
|
||||||
|
}, [inventoryItems, searchTerm]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ accessorKey: "name", header: "Name" },
|
||||||
|
{ accessorKey: "category", header: "Category" },
|
||||||
|
{ accessorKey: "type", header: "Type" },
|
||||||
|
{ accessorKey: "quantity", header: "Quantity" },
|
||||||
|
{ accessorKey: "lastUpdated", header: "Last Updated" },
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: (info: { getValue: () => string }) => {
|
||||||
|
const status = info.getValue();
|
||||||
|
|
||||||
|
let statusClass = ""; // default status class
|
||||||
|
|
||||||
|
if (status === "Low Stock") {
|
||||||
|
statusClass = "bg-yellow-300"; // yellow for low stock
|
||||||
|
} else if (status === "Out of Stock") {
|
||||||
|
statusClass = "bg-red-500 text-white"; // red for out of stock
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter items based on selected type.
|
return (
|
||||||
const filteredItems =
|
<Badge className={`py-1 px-2 rounded-md ${statusClass}`}>
|
||||||
inventoryType === "all"
|
{status}
|
||||||
? inventoryItems
|
</Badge>
|
||||||
: inventoryItems.filter((item) =>
|
);
|
||||||
inventoryType === "plantation" ? item.type === "Plantation" : item.type === "Fertilizer"
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "edit",
|
||||||
|
header: "Edit",
|
||||||
|
cell: () => <EditInventoryItem />,
|
||||||
|
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 (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (isError)
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
Error loading inventory data.
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -55,95 +137,102 @@ export default function InventoryPage() {
|
|||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-6">
|
||||||
<h1 className="text-2xl font-bold tracking-tight mb-6">Inventory</h1>
|
<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 flex-col sm:flex-row gap-4 mb-6">
|
||||||
<div className="flex gap-2">
|
<Search className="mt-1" />
|
||||||
<Button
|
<Input
|
||||||
variant={inventoryType === "all" ? "default" : "outline"}
|
type="search"
|
||||||
onClick={() => setInventoryType("all")}
|
placeholder="Search by name..."
|
||||||
className="w-24">
|
value={searchTerm}
|
||||||
All
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
</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 />
|
<AddInventoryItem />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="border rounded-md">
|
<div className="border rounded-md">
|
||||||
<h3 className="px-4 py-2 border-b font-medium">Table Fields</h3>
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHead>Name</TableHead>
|
<TableRow key={headerGroup.id}>
|
||||||
<TableHead>Category</TableHead>
|
{headerGroup.headers.map((header) => (
|
||||||
<TableHead>Type</TableHead>
|
<TableHead
|
||||||
<TableHead className="text-right">Quantity</TableHead>
|
key={header.id}
|
||||||
<TableHead>Last Updated</TableHead>
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
<TableHead>Status</TableHead>
|
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>
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredItems.length === 0 ? (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<TableRow>
|
<TableRow key={row.id}>
|
||||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
{row.getVisibleCells().map((cell) => (
|
||||||
No inventory items found
|
<TableCell key={cell.id}>
|
||||||
</TableCell>
|
{flexRender(
|
||||||
</TableRow>
|
cell.column.columnDef.cell,
|
||||||
) : (
|
cell.getContext()
|
||||||
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>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -46,6 +46,7 @@
|
|||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"sonner": "^2.0.1",
|
"sonner": "^2.0.1",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user