Merge branch 'main' into feature-farm-setup

This commit is contained in:
Sosokker 2025-04-04 16:38:46 +07:00
commit 9d320ccb2f
18 changed files with 6638 additions and 2654 deletions

View File

@ -37,6 +37,7 @@ type api struct {
inventoryRepo domain.InventoryRepository inventoryRepo domain.InventoryRepository
harvestRepo domain.HarvestRepository harvestRepo domain.HarvestRepository
analyticsRepo domain.AnalyticsRepository analyticsRepo domain.AnalyticsRepository
knowledgeHubRepo domain.KnowledgeHubRepository
weatherFetcher domain.WeatherFetcher weatherFetcher domain.WeatherFetcher
@ -61,8 +62,9 @@ func NewAPI(
client := &http.Client{} client := &http.Client{}
userRepository := repository.NewPostgresUser(pool) userRepository := repository.NewPostgresUser(pool)
harvestRepository := repository.NewPostgresHarvest(pool)
plantRepository := repository.NewPostgresPlant(pool) plantRepository := repository.NewPostgresPlant(pool)
knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool)
harvestRepository := repository.NewPostgresHarvest(pool)
owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger) owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger)
cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL) cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL)
@ -94,7 +96,7 @@ func NewAPI(
inventoryRepo: inventoryRepo, inventoryRepo: inventoryRepo,
harvestRepo: harvestRepository, harvestRepo: harvestRepository,
analyticsRepo: analyticsRepo, analyticsRepo: analyticsRepo,
knowledgeHubRepo: knowledgeHubRepository,
weatherFetcher: cachedWeatherFetcher, weatherFetcher: cachedWeatherFetcher,
chatService: chatService, chatService: chatService,
@ -138,6 +140,7 @@ func (a *api) Routes() *chi.Mux {
a.registerAuthRoutes(r, api) a.registerAuthRoutes(r, api)
a.registerCropRoutes(r, api) a.registerCropRoutes(r, api)
a.registerPlantRoutes(r, api) a.registerPlantRoutes(r, api)
a.registerKnowledgeHubRoutes(r, api)
a.registerOauthRoutes(r, api) a.registerOauthRoutes(r, api)
a.registerChatRoutes(r, api) a.registerChatRoutes(r, api)
a.registerInventoryRoutes(r, api) a.registerInventoryRoutes(r, api)

View File

@ -77,10 +77,10 @@ type InventoryItemResponse struct {
Category InventoryCategory `json:"category"` Category InventoryCategory `json:"category"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
Unit HarvestUnit `json:"unit"` Unit HarvestUnit `json:"unit"`
DateAdded time.Time `json:"date_added"` DateAdded time.Time `json:"dateAdded"`
Status InventoryStatus `json:"status"` Status InventoryStatus `json:"status"`
CreatedAt time.Time `json:"created_at,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updatedAt,omitempty"`
} }
type InventoryStatus struct { type InventoryStatus struct {
@ -100,14 +100,13 @@ type HarvestUnit struct {
type CreateInventoryItemInput struct { type CreateInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
Body struct { Body struct {
Name string `json:"name" required:"true"` Name string `json:"name" required:"true"`
CategoryID int `json:"category_id" required:"true"` CategoryID int `json:"categoryId" required:"true"`
Quantity float64 `json:"quantity" required:"true"` Quantity float64 `json:"quantity" required:"true"`
UnitID int `json:"unit_id" required:"true"` UnitID int `json:"unitId" required:"true"`
DateAdded time.Time `json:"date_added" required:"true"` DateAdded time.Time `json:"dateAdded" required:"true"`
StatusID int `json:"status_id" required:"true"` StatusID int `json:"statusId" required:"true"`
} }
} }
@ -119,15 +118,14 @@ type CreateInventoryItemOutput struct {
type UpdateInventoryItemInput struct { type UpdateInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
ID string `path:"id"` ID string `path:"id"`
Body struct { Body struct {
Name string `json:"name"` Name string `json:"name"`
CategoryID int `json:"category_id"` CategoryID int `json:"categoryId"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
UnitID int `json:"unit_id"` UnitID int `json:"unitId"`
DateAdded time.Time `json:"date_added"` DateAdded time.Time `json:"dateAdded"`
StatusID int `json:"status_id"` StatusID int `json:"statusId"`
} }
} }
@ -137,14 +135,13 @@ type UpdateInventoryItemOutput struct {
type GetInventoryItemsInput struct { type GetInventoryItemsInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"` CategoryID int `query:"categoryId"`
CategoryID int `query:"category_id"` StatusID int `query:"statusId"`
StatusID int `query:"status_id"` StartDate time.Time `query:"startDate" format:"date-time"`
StartDate time.Time `query:"start_date" format:"date-time"` EndDate time.Time `query:"endDate" format:"date-time"`
EndDate time.Time `query:"end_date" format:"date-time"`
SearchQuery string `query:"search"` SearchQuery string `query:"search"`
SortBy string `query:"sort_by" enum:"name,quantity,date_added,created_at"` SortBy string `query:"sortBy" enum:"name,quantity,dateAdded,createdAt"`
SortOrder string `query:"sort_order" enum:"asc,desc" default:"desc"` SortOrder string `query:"sortOrder" enum:"asc,desc" default:"desc"`
} }
type GetInventoryItemsOutput struct { type GetInventoryItemsOutput struct {
@ -153,7 +150,7 @@ type GetInventoryItemsOutput struct {
type GetInventoryItemInput struct { type GetInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"` UserID string `header:"userId" required:"true" example:"user-uuid"`
ID string `path:"id"` ID string `path:"id"`
} }
@ -163,7 +160,6 @@ type GetInventoryItemOutput struct {
type DeleteInventoryItemInput struct { type DeleteInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
ID string `path:"id"` ID string `path:"id"`
} }
@ -186,8 +182,9 @@ type GetHarvestUnitsOutput struct {
} }
func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInventoryItemInput) (*CreateInventoryItemOutput, error) { func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInventoryItemInput) (*CreateInventoryItemOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header)
item := &domain.InventoryItem{ item := &domain.InventoryItem{
UserID: input.UserID, UserID: userID,
Name: input.Body.Name, Name: input.Body.Name,
CategoryID: input.Body.CategoryID, CategoryID: input.Body.CategoryID,
Quantity: input.Body.Quantity, Quantity: input.Body.Quantity,
@ -200,7 +197,7 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven
return nil, huma.Error422UnprocessableEntity(err.Error()) return nil, huma.Error422UnprocessableEntity(err.Error())
} }
err := a.inventoryRepo.CreateOrUpdate(ctx, item) err = a.inventoryRepo.CreateOrUpdate(ctx, item)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -211,8 +208,9 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven
} }
func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInventoryItemsInput) (*GetInventoryItemsOutput, error) { func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInventoryItemsInput) (*GetInventoryItemsOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header)
filter := domain.InventoryFilter{ filter := domain.InventoryFilter{
UserID: input.UserID, UserID: userID,
CategoryID: input.CategoryID, CategoryID: input.CategoryID,
StatusID: input.StatusID, StatusID: input.StatusID,
StartDate: input.StartDate, StartDate: input.StartDate,
@ -225,7 +223,7 @@ func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInve
Direction: input.SortOrder, Direction: input.SortOrder,
} }
items, err := a.inventoryRepo.GetByUserID(ctx, input.UserID, filter, sort) items, err := a.inventoryRepo.GetByUserID(ctx, userID, filter, sort)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -286,7 +284,8 @@ func (a *api) getInventoryItemHandler(ctx context.Context, input *GetInventoryIt
} }
func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInventoryItemInput) (*UpdateInventoryItemOutput, error) { func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInventoryItemInput) (*UpdateInventoryItemOutput, error) {
item, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID) userID, err := a.getUserIDFromHeader(input.Header)
item, err := a.inventoryRepo.GetByID(ctx, input.ID, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -319,7 +318,7 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven
return nil, err return nil, err
} }
updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID) updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -347,7 +346,8 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven
} }
func (a *api) deleteInventoryItemHandler(ctx context.Context, input *DeleteInventoryItemInput) (*DeleteInventoryItemOutput, error) { func (a *api) deleteInventoryItemHandler(ctx context.Context, input *DeleteInventoryItemInput) (*DeleteInventoryItemOutput, error) {
err := a.inventoryRepo.Delete(ctx, input.ID, input.UserID) userID, err := a.getUserIDFromHeader(input.Header)
err = a.inventoryRepo.Delete(ctx, input.ID, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -0,0 +1,372 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/forfarm/backend/internal/domain"
"github.com/go-chi/chi/v5"
"github.com/gofrs/uuid"
)
func (a *api) registerKnowledgeHubRoutes(_ chi.Router, api huma.API) {
tags := []string{"knowledge-hub"}
prefix := "/knowledge-hub"
huma.Register(api, huma.Operation{
OperationID: "getAllKnowledgeArticles",
Method: http.MethodGet,
Path: prefix,
Tags: tags,
}, a.getAllKnowledgeArticlesHandler)
huma.Register(api, huma.Operation{
OperationID: "getKnowledgeArticleByID",
Method: http.MethodGet,
Path: prefix + "/{uuid}",
Tags: tags,
}, a.getKnowledgeArticleByIDHandler)
huma.Register(api, huma.Operation{
OperationID: "getKnowledgeArticlesByCategory",
Method: http.MethodGet,
Path: prefix + "/category/{category}",
Tags: tags,
}, a.getKnowledgeArticlesByCategoryHandler)
huma.Register(api, huma.Operation{
OperationID: "createOrUpdateKnowledgeArticle",
Method: http.MethodPost,
Path: prefix,
Tags: tags,
}, a.createOrUpdateKnowledgeArticleHandler)
huma.Register(api, huma.Operation{
OperationID: "getArticleTableOfContents",
Method: http.MethodGet,
Path: prefix + "/{uuid}/toc",
Tags: tags,
}, a.getArticleTableOfContentsHandler)
huma.Register(api, huma.Operation{
OperationID: "getArticleRelatedArticles",
Method: http.MethodGet,
Path: prefix + "/{uuid}/related",
Tags: tags,
}, a.getArticleRelatedArticlesHandler)
huma.Register(api, huma.Operation{
OperationID: "createRelatedArticle",
Method: http.MethodPost,
Path: prefix + "/{uuid}/related",
Tags: tags,
}, a.createRelatedArticleHandler)
huma.Register(api, huma.Operation{
OperationID: "generateTableOfContents",
Method: http.MethodPost,
Path: prefix + "/{uuid}/generate-toc",
Tags: tags,
}, a.generateTOCHandler)
}
type GetKnowledgeArticlesOutput struct {
Body struct {
Articles []domain.KnowledgeArticle `json:"articles"`
} `json:"body"`
}
type GetKnowledgeArticleByIDOutput struct {
Body struct {
Article domain.KnowledgeArticle `json:"article"`
} `json:"body"`
}
type CreateOrUpdateKnowledgeArticleInput struct {
Body struct {
UUID string `json:"uuid,omitempty"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
PublishDate time.Time `json:"publish_date"`
ReadTime string `json:"read_time"`
Categories []string `json:"categories"`
ImageURL string `json:"image_url"`
} `json:"body"`
}
type CreateOrUpdateKnowledgeArticleOutput struct {
Body struct {
Article domain.KnowledgeArticle `json:"article"`
} `json:"body"`
}
type GetTableOfContentsOutput struct {
Body struct {
TableOfContents []domain.TableOfContent `json:"table_of_contents"`
} `json:"body"`
}
type GetRelatedArticlesOutput struct {
Body struct {
RelatedArticles []domain.RelatedArticle `json:"related_articles"`
} `json:"body"`
}
type CreateRelatedArticleInput struct {
UUID string `path:"uuid"`
Body struct {
RelatedTitle string `json:"related_title"`
RelatedTag string `json:"related_tag"`
} `json:"body"`
}
type GenerateTOCInput struct {
UUID string `path:"uuid"`
}
type GenerateTOCOutput struct {
Body struct {
TableOfContents []domain.TableOfContent `json:"table_of_contents"`
} `json:"body"`
}
func (a *api) getAllKnowledgeArticlesHandler(ctx context.Context, input *struct{}) (*GetKnowledgeArticlesOutput, error) {
resp := &GetKnowledgeArticlesOutput{}
articles, err := a.knowledgeHubRepo.GetAllArticles(ctx)
if err != nil {
return nil, err
}
resp.Body.Articles = articles
return resp, nil
}
func (a *api) getKnowledgeArticleByIDHandler(ctx context.Context, input *struct {
UUID string `path:"uuid"`
}) (*GetKnowledgeArticleByIDOutput, error) {
resp := &GetKnowledgeArticleByIDOutput{}
if input.UUID == "" {
return nil, huma.Error400BadRequest("UUID parameter is required")
}
if _, err := uuid.FromString(input.UUID); err != nil {
return nil, huma.Error400BadRequest("invalid UUID format")
}
article, err := a.knowledgeHubRepo.GetArticleByID(ctx, input.UUID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, huma.Error404NotFound("article not found")
}
return nil, err
}
resp.Body.Article = article
return resp, nil
}
func (a *api) getKnowledgeArticlesByCategoryHandler(ctx context.Context, input *struct {
Category string `path:"category"`
}) (*GetKnowledgeArticlesOutput, error) {
resp := &GetKnowledgeArticlesOutput{}
if input.Category == "" {
return nil, huma.Error400BadRequest("category parameter is required")
}
articles, err := a.knowledgeHubRepo.GetArticlesByCategory(ctx, input.Category)
if err != nil {
return nil, err
}
resp.Body.Articles = articles
return resp, nil
}
func (a *api) createOrUpdateKnowledgeArticleHandler(ctx context.Context, input *CreateOrUpdateKnowledgeArticleInput) (*CreateOrUpdateKnowledgeArticleOutput, error) {
resp := &CreateOrUpdateKnowledgeArticleOutput{}
if input.Body.Title == "" {
return nil, huma.Error400BadRequest("title is required")
}
if input.Body.Content == "" {
return nil, huma.Error400BadRequest("content is required")
}
if input.Body.Author == "" {
return nil, huma.Error400BadRequest("author is required")
}
if len(input.Body.Categories) == 0 {
return nil, huma.Error400BadRequest("at least one category is required")
}
if input.Body.UUID != "" {
if _, err := uuid.FromString(input.Body.UUID); err != nil {
return nil, huma.Error400BadRequest("invalid UUID format")
}
}
article := &domain.KnowledgeArticle{
UUID: input.Body.UUID,
Title: input.Body.Title,
Content: input.Body.Content,
Author: input.Body.Author,
PublishDate: input.Body.PublishDate,
ReadTime: input.Body.ReadTime,
Categories: input.Body.Categories,
ImageURL: input.Body.ImageURL,
}
if err := a.knowledgeHubRepo.CreateOrUpdateArticle(ctx, article); err != nil {
return nil, err
}
resp.Body.Article = *article
return resp, nil
}
func (a *api) getArticleTableOfContentsHandler(ctx context.Context, input *struct {
UUID string `path:"uuid"`
}) (*GetTableOfContentsOutput, error) {
resp := &GetTableOfContentsOutput{}
if input.UUID == "" {
return nil, huma.Error400BadRequest("UUID parameter is required")
}
if _, err := uuid.FromString(input.UUID); err != nil {
return nil, huma.Error400BadRequest("invalid UUID format")
}
toc, err := a.knowledgeHubRepo.GetTableOfContents(ctx, input.UUID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, huma.Error404NotFound("article not found")
}
return nil, err
}
resp.Body.TableOfContents = toc
return resp, nil
}
func (a *api) getArticleRelatedArticlesHandler(ctx context.Context, input *struct {
UUID string `path:"uuid"`
}) (*GetRelatedArticlesOutput, error) {
resp := &GetRelatedArticlesOutput{}
if input.UUID == "" {
return nil, huma.Error400BadRequest("UUID parameter is required")
}
if _, err := uuid.FromString(input.UUID); err != nil {
return nil, huma.Error400BadRequest("invalid UUID format")
}
related, err := a.knowledgeHubRepo.GetRelatedArticles(ctx, input.UUID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, huma.Error404NotFound("article not found")
}
return nil, err
}
resp.Body.RelatedArticles = related
return resp, nil
}
func (a *api) createRelatedArticleHandler(
ctx context.Context,
input *CreateRelatedArticleInput,
) (*struct{}, error) {
// Validate main article exists
if _, err := a.knowledgeHubRepo.GetArticleByID(ctx, input.UUID); err != nil {
return nil, huma.Error404NotFound("main article not found")
}
// Create related article
related := &domain.RelatedArticle{
RelatedTitle: input.Body.RelatedTitle,
RelatedTag: input.Body.RelatedTag,
}
if err := a.knowledgeHubRepo.CreateRelatedArticle(ctx, input.UUID, related); err != nil {
return nil, huma.Error500InternalServerError("failed to create related article")
}
return nil, nil
}
func generateTOCFromContent(content string) []domain.TableOfContent {
var toc []domain.TableOfContent
lines := strings.Split(content, "\n")
order := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
order++
toc = append(toc, domain.TableOfContent{
Title: strings.TrimPrefix(line, "# "),
Level: 1,
Order: order,
})
} else if strings.HasPrefix(line, "## ") {
order++
toc = append(toc, domain.TableOfContent{
Title: strings.TrimPrefix(line, "## "),
Level: 2,
Order: order,
})
} else if strings.HasPrefix(line, "### ") {
order++
toc = append(toc, domain.TableOfContent{
Title: strings.TrimPrefix(line, "### "),
Level: 3,
Order: order,
})
}
// Add more levels if needed
}
return toc
}
func (a *api) generateTOCHandler(
ctx context.Context,
input *GenerateTOCInput,
) (*GenerateTOCOutput, error) {
resp := &GenerateTOCOutput{}
// Validate UUID format
if _, err := uuid.FromString(input.UUID); err != nil {
return nil, huma.Error400BadRequest("invalid UUID format")
}
// Get the article
article, err := a.knowledgeHubRepo.GetArticleByID(ctx, input.UUID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, huma.Error404NotFound("article not found")
}
return nil, err
}
// Generate TOC from content
tocItems := generateTOCFromContent(article.Content)
// Save to database
if err := a.knowledgeHubRepo.CreateTableOfContents(ctx, input.UUID, tocItems); err != nil {
return nil, huma.Error500InternalServerError("failed to save table of contents")
}
resp.Body.TableOfContents = tocItems
return resp, nil
}

View File

@ -0,0 +1,78 @@
package domain
import (
"context"
"fmt"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
)
type KnowledgeArticle struct {
UUID string
Title string
Content string
Author string
PublishDate time.Time
ReadTime string
Categories []string
ImageURL string
CreatedAt time.Time
UpdatedAt time.Time
}
func (k *KnowledgeArticle) Validate() error {
return validation.ValidateStruct(k,
validation.Field(&k.Title, validation.Required),
validation.Field(&k.Content, validation.Required),
validation.Field(&k.Author, validation.Required),
validation.Field(&k.PublishDate, validation.Required),
validation.Field(&k.ImageURL,
validation.By(func(value interface{}) error {
if url, ok := value.(string); ok && url != "" {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return fmt.Errorf("must be a valid URL starting with http:// or https://")
}
}
return nil
}),
),
)
}
type TableOfContent struct {
UUID string `json:"uuid"`
ArticleID string `json:"article_id"`
Title string `json:"title"`
Level int `json:"level"`
Order int `json:"order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RelatedArticle struct {
UUID string
ArticleID string
RelatedTitle string
RelatedTag string
CreatedAt time.Time
UpdatedAt time.Time
}
type KnowledgeHubRepository interface {
// Article methods
GetArticleByID(context.Context, string) (KnowledgeArticle, error)
GetArticlesByCategory(ctx context.Context, category string) ([]KnowledgeArticle, error)
GetAllArticles(ctx context.Context) ([]KnowledgeArticle, error)
CreateOrUpdateArticle(context.Context, *KnowledgeArticle) error
DeleteArticle(context.Context, string) error
// Table of Contents methods
GetTableOfContents(ctx context.Context, articleID string) ([]TableOfContent, error)
CreateTableOfContents(ctx context.Context, articleID string, items []TableOfContent) error
// Related Articles methods
GetRelatedArticles(ctx context.Context, articleID string) ([]RelatedArticle, error)
CreateRelatedArticle(ctx context.Context, articleID string, related *RelatedArticle) error
}

View File

@ -11,4 +11,5 @@ type Connection interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error) Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row QueryRow(context.Context, string, ...interface{}) pgx.Row
BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error)
} }

View File

@ -0,0 +1,265 @@
package repository
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"strings"
"github.com/forfarm/backend/internal/domain"
"github.com/google/uuid"
)
type postgresKnowledgeHubRepository struct {
conn Connection
}
func NewPostgresKnowledgeHub(conn Connection) domain.KnowledgeHubRepository {
return &postgresKnowledgeHubRepository{conn: conn}
}
func (p *postgresKnowledgeHubRepository) fetchArticles(ctx context.Context, query string, args ...interface{}) ([]domain.KnowledgeArticle, error) {
rows, err := p.conn.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []domain.KnowledgeArticle
for rows.Next() {
var a domain.KnowledgeArticle
if err := rows.Scan(
&a.UUID,
&a.Title,
&a.Content,
&a.Author,
&a.PublishDate,
&a.ReadTime,
&a.Categories,
&a.ImageURL,
&a.CreatedAt,
&a.UpdatedAt,
); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
func (p *postgresKnowledgeHubRepository) GetArticleByID(ctx context.Context, uuid string) (domain.KnowledgeArticle, error) {
query := `
SELECT uuid, title, content, author, publish_date, read_time, categories, image_url, created_at, updated_at
FROM knowledge_articles
WHERE uuid = $1`
articles, err := p.fetchArticles(ctx, query, uuid)
if err != nil {
return domain.KnowledgeArticle{}, err
}
if len(articles) == 0 {
return domain.KnowledgeArticle{}, domain.ErrNotFound
}
return articles[0], nil
}
func (p *postgresKnowledgeHubRepository) GetArticlesByCategory(ctx context.Context, category string) ([]domain.KnowledgeArticle, error) {
query := `
SELECT uuid, title, content, author, publish_date, read_time, categories, image_url, created_at, updated_at
FROM knowledge_articles
WHERE $1 = ANY(categories)`
return p.fetchArticles(ctx, query, category)
}
func (p *postgresKnowledgeHubRepository) GetAllArticles(ctx context.Context) ([]domain.KnowledgeArticle, error) {
query := `
SELECT uuid, title, content, author, publish_date, read_time, categories, image_url, created_at, updated_at
FROM knowledge_articles`
return p.fetchArticles(ctx, query)
}
func (p *postgresKnowledgeHubRepository) CreateOrUpdateArticle(
ctx context.Context,
article *domain.KnowledgeArticle,
) error {
if strings.TrimSpace(article.UUID) == "" {
article.UUID = uuid.New().String()
}
query := `
INSERT INTO knowledge_articles
(uuid, title, content, author, publish_date, read_time, categories, image_url, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
ON CONFLICT (uuid) DO UPDATE
SET title = EXCLUDED.title,
content = EXCLUDED.content,
author = EXCLUDED.author,
publish_date = EXCLUDED.publish_date,
read_time = EXCLUDED.read_time,
categories = EXCLUDED.categories,
image_url = EXCLUDED.image_url,
updated_at = NOW()
RETURNING uuid, created_at, updated_at`
return p.conn.QueryRow(
ctx,
query,
article.UUID,
article.Title,
article.Content,
article.Author,
article.PublishDate,
article.ReadTime,
article.Categories,
article.ImageURL,
).Scan(&article.UUID, &article.CreatedAt, &article.UpdatedAt)
}
func (p *postgresKnowledgeHubRepository) DeleteArticle(ctx context.Context, uuid string) error {
query := `DELETE FROM knowledge_articles WHERE uuid = $1`
_, err := p.conn.Exec(ctx, query, uuid)
return err
}
func (p *postgresKnowledgeHubRepository) fetchTableOfContents(ctx context.Context, query string, args ...interface{}) ([]domain.TableOfContent, error) {
rows, err := p.conn.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var tocItems []domain.TableOfContent
for rows.Next() {
var t domain.TableOfContent
if err := rows.Scan(
&t.UUID,
&t.ArticleID,
&t.Title,
&t.Order,
&t.CreatedAt,
&t.UpdatedAt,
); err != nil {
return nil, err
}
tocItems = append(tocItems, t)
}
return tocItems, nil
}
func (p *postgresKnowledgeHubRepository) GetTableOfContents(ctx context.Context, articleID string) ([]domain.TableOfContent, error) {
query := `
SELECT uuid, article_id, title, "order", created_at, updated_at
FROM table_of_contents
WHERE article_id = $1
ORDER BY "order" ASC`
return p.fetchTableOfContents(ctx, query, articleID)
}
func (p *postgresKnowledgeHubRepository) fetchRelatedArticles(ctx context.Context, query string, args ...interface{}) ([]domain.RelatedArticle, error) {
rows, err := p.conn.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var relatedArticles []domain.RelatedArticle
for rows.Next() {
var r domain.RelatedArticle
if err := rows.Scan(
&r.UUID,
&r.ArticleID,
&r.RelatedTitle,
&r.RelatedTag,
&r.CreatedAt,
&r.UpdatedAt,
); err != nil {
return nil, err
}
relatedArticles = append(relatedArticles, r)
}
return relatedArticles, nil
}
func (p *postgresKnowledgeHubRepository) GetRelatedArticles(ctx context.Context, articleID string) ([]domain.RelatedArticle, error) {
query := `
SELECT uuid, article_id, related_title, related_tag, created_at, updated_at
FROM related_articles
WHERE article_id = $1`
return p.fetchRelatedArticles(ctx, query, articleID)
}
func (p *postgresKnowledgeHubRepository) CreateRelatedArticle(
ctx context.Context,
articleID string,
related *domain.RelatedArticle,
) error {
related.UUID = uuid.New().String()
related.ArticleID = articleID
query := `
INSERT INTO related_articles
(uuid, article_id, related_title, related_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())`
_, err := p.conn.Exec(
ctx,
query,
related.UUID,
related.ArticleID,
related.RelatedTitle,
related.RelatedTag,
)
return err
}
func (p *postgresKnowledgeHubRepository) CreateTableOfContents(
ctx context.Context,
articleID string,
items []domain.TableOfContent,
) error {
// Begin transaction
tx, err := p.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
// First delete existing TOC items for this article
_, err = tx.Exec(ctx, `DELETE FROM table_of_contents WHERE article_id = $1`, articleID)
if err != nil {
return fmt.Errorf("failed to delete existing TOC items: %w", err)
}
// Insert new TOC items
for _, item := range items {
item.UUID = uuid.New().String()
_, err = tx.Exec(ctx, `
INSERT INTO table_of_contents
(uuid, article_id, title, level, "order", created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`,
item.UUID,
articleID,
item.Title,
item.Level,
item.Order,
)
if err != nil {
return fmt.Errorf("failed to insert TOC item: %w", err)
}
}
// Commit transaction
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}

View File

@ -0,0 +1,37 @@
package service
import (
"github.com/forfarm/backend/internal/domain"
"regexp"
"strings"
)
type TOCGenerator struct{}
func NewTOCGenerator() *TOCGenerator {
return &TOCGenerator{}
}
func (g *TOCGenerator) GenerateFromContent(content string) []domain.TableOfContent {
var toc []domain.TableOfContent
lines := strings.Split(content, "\n")
order := 0
headerRegex := regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
for _, line := range lines {
if matches := headerRegex.FindStringSubmatch(line); matches != nil {
order++
level := len(matches[1]) // Number of # indicates level
title := matches[2]
toc = append(toc, domain.TableOfContent{
Title: title,
Level: level,
Order: order,
})
}
}
return toc
}

View File

@ -0,0 +1,41 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE knowledge_articles (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
author TEXT NOT NULL,
publish_date TIMESTAMPTZ NOT NULL,
read_time TEXT NOT NULL,
categories TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE table_of_contents (
uuid UUID PRIMARY KEY,
article_id UUID NOT NULL REFERENCES knowledge_articles(uuid),
title TEXT NOT NULL,
level INT NOT NULL,
"order" INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE related_articles (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL,
related_title TEXT NOT NULL,
related_tag TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_related_article FOREIGN KEY (article_id) REFERENCES knowledge_articles(uuid) ON DELETE CASCADE
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS related_articles;
DROP TABLE IF EXISTS table_of_contents;
DROP TABLE IF EXISTS knowledge_articles;
-- +goose StatementEnd

View File

@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE knowledge_articles
ADD COLUMN image_url TEXT;
-- +goose Down
ALTER TABLE knowledge_articles
DROP COLUMN image_url;

12
frontend/api/harvest.ts Normal file
View File

@ -0,0 +1,12 @@
import axiosInstance from "./config";
import type { HarvestUnits } from "@/types";
export async function fetchHarvestUnits(): Promise<HarvestUnits[]> {
try {
const response = await axiosInstance.get<HarvestUnits[]>("/harvest/units");
return response.data;
} catch (error) {
console.error("Error fetching inventory status:", error);
return [];
}
}

View File

@ -1,9 +1,12 @@
import axiosInstance from "./config"; import axiosInstance from "./config";
import type { import type {
InventoryItem, InventoryItem,
InventoryStatus,
InventoryItemCategory,
CreateInventoryItemInput, CreateInventoryItemInput,
InventoryItemStatus, EditInventoryItemInput,
} from "@/types"; } from "@/types";
import { AxiosError } from "axios";
/** /**
* Simulates an API call to fetch inventory items. * Simulates an API call to fetch inventory items.
@ -12,9 +15,9 @@ import type {
* *
* *
*/ */
export async function fetchInventoryStatus(): Promise<InventoryItemStatus[]> { export async function fetchInventoryStatus(): Promise<InventoryStatus[]> {
try { try {
const response = await axiosInstance.get<InventoryItemStatus[]>( const response = await axiosInstance.get<InventoryStatus[]>(
"/inventory/status" "/inventory/status"
); );
return response.data; return response.data;
@ -23,96 +26,136 @@ export async function fetchInventoryStatus(): Promise<InventoryItemStatus[]> {
return []; return [];
} }
} }
export async function fetchInventoryCategory(): Promise<
InventoryItemCategory[]
> {
try {
const response = await axiosInstance.get<InventoryItemCategory[]>(
"/inventory/category"
);
return response.data;
} catch (error) {
console.error("Error fetching inventory status:", error);
return [];
}
}
export async function fetchInventoryItems(): Promise<InventoryItem[]> { export async function fetchInventoryItems(): Promise<InventoryItem[]> {
try { try {
const response = await axiosInstance.get<InventoryItem[]>("/api/inventory"); const response = await axiosInstance.get<InventoryItem[]>("/inventory");
return response.data; return response.data;
} catch (error) { } catch (error) {
// Fallback dummy data console.error("Error while fetching inventory items! " + error);
return [ throw error;
{
id: 1,
name: "Tomato Seeds",
category: "Seeds",
type: "Plantation",
quantity: 500,
unit: "packets",
lastUpdated: "2023-03-01",
status: "In Stock",
},
{
id: 2,
name: "NPK Fertilizer",
category: "Fertilizer",
type: "Fertilizer",
quantity: 200,
unit: "kg",
lastUpdated: "2023-03-05",
status: "Low Stock",
},
{
id: 3,
name: "Corn Seeds",
category: "Seeds",
type: "Plantation",
quantity: 300,
unit: "packets",
lastUpdated: "2023-03-10",
status: "In Stock",
},
{
id: 4,
name: "Organic Compost",
category: "Fertilizer",
type: "Fertilizer",
quantity: 150,
unit: "kg",
lastUpdated: "2023-03-15",
status: "Out Of Stock",
},
{
id: 5,
name: "Wheat Seeds",
category: "Seeds",
type: "Plantation",
quantity: 250,
unit: "packets",
lastUpdated: "2023-03-20",
status: "In Stock",
},
];
} }
} }
/**
* Simulates creating a new inventory item.
* Uses axios POST and if unavailable, returns a simulated response.
*
* Note: The function accepts all fields except id, lastUpdated, and status.
*/
export async function createInventoryItem( export async function createInventoryItem(
item: Omit<InventoryItem, "id" | "lastUpdated" | "status"> item: Omit<CreateInventoryItemInput, "id" | "lastUpdated" | "status">
): Promise<InventoryItem> { ): Promise<InventoryItem> {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
try { try {
const response = await axiosInstance.post<InventoryItem>( const response = await axiosInstance.post<InventoryItem>(
"/api/inventory", "/inventory",
item item
); );
return response.data; return response.data;
} catch (error) { } catch (error: unknown) {
// Simulate successful creation if API endpoint is not available // Cast error to AxiosError to safely access response properties
return { if (error instanceof AxiosError && error.response) {
id: Math.floor(Math.random() * 1000), // Log the detailed error message
name: item.name, console.error("Error while creating Inventory Item!");
category: item.category, console.error("Response Status:", error.response.status); // e.g., 422
type: item.type, console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend
quantity: item.quantity, console.error("Full Error Response:", error.response.data); // Entire error object (including details)
unit: item.unit,
lastUpdated: new Date().toISOString(), // Throw a new error with a more specific message
status: "In Stock", throw new Error(
}; `Failed to create inventory item: ${
error.response.data?.detail || error.message
}`
);
} else {
// Handle other errors (e.g., network errors or unknown errors)
console.error(
"Error while creating Inventory Item, unknown error:",
error
);
throw new Error(
"Failed to create inventory item: " +
(error instanceof Error ? error.message : "Unknown error")
);
}
}
}
export async function deleteInventoryItem(id: string) {
try {
const response = await axiosInstance.delete("/inventory/" + id);
return response.data;
} catch (error: unknown) {
// Cast error to AxiosError to safely access response properties
if (error instanceof AxiosError && error.response) {
// Log the detailed error message
console.error("Error while deleting Inventory Item!");
console.error("Response Status:", error.response.status); // e.g., 422
console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend
console.error("Full Error Response:", error.response.data); // Entire error object (including details)
// Throw a new error with a more specific message
throw new Error(
`Failed to delete inventory item: ${
error.response.data?.detail || error.message
}`
);
} else {
// Handle other errors (e.g., network errors or unknown errors)
console.error(
"Error while deleting Inventory Item, unknown error:",
error
);
throw new Error(
"Failed to delete inventory item: " +
(error instanceof Error ? error.message : "Unknown error")
);
}
}
}
export async function updateInventoryItem(
id: string,
item: EditInventoryItemInput
) {
// console.log(id);
try {
const response = await axiosInstance.put<InventoryItem>(
`/inventory/${id}`,
item
);
return response.data;
} catch (error: unknown) {
// Cast error to AxiosError to safely access response properties
if (error instanceof AxiosError && error.response) {
// Log the detailed error message
console.error("Error while deleting Inventory Item!");
console.error("Response Status:", error.response.status); // e.g., 422
console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend
console.error("Full Error Response:", error.response.data); // Entire error object (including details)
// Throw a new error with a more specific message
throw new Error(
`Failed to delete inventory item: ${
error.response.data?.detail || error.message
}`
);
} else {
// Handle other errors (e.g., network errors or unknown errors)
console.error(
"Error while deleting Inventory Item, unknown error:",
error
);
throw new Error(
"Failed to delete inventory item: " +
(error instanceof Error ? error.message : "Unknown error")
);
}
} }
} }

View File

@ -4,7 +4,6 @@ import { useState } from "react";
import { CalendarIcon } from "lucide-react"; import { CalendarIcon } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
import { import {
@ -18,7 +17,11 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -30,48 +33,95 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createInventoryItem } from "@/api/inventory"; import { createInventoryItem } from "@/api/inventory";
import type { CreateInventoryItemInput } from "@/types"; import type {
CreateInventoryItemInput,
InventoryStatus,
InventoryItemCategory,
HarvestUnits,
} from "@/types";
export function AddInventoryItem() { interface AddInventoryItemProps {
inventoryCategory: InventoryItemCategory[];
inventoryStatus: InventoryStatus[];
harvestUnits: HarvestUnits[];
}
export function AddInventoryItem({
inventoryCategory,
inventoryStatus,
harvestUnits,
}: AddInventoryItemProps) {
const [date, setDate] = useState<Date | undefined>(); const [date, setDate] = useState<Date | undefined>();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [itemName, setItemName] = useState(""); const [itemName, setItemName] = useState("");
const [itemType, setItemType] = useState("");
const [itemCategory, setItemCategory] = useState(""); const [itemCategory, setItemCategory] = useState("");
const [itemQuantity, setItemQuantity] = useState(0); const [itemQuantity, setItemQuantity] = useState(0);
const [itemUnit, setItemUnit] = useState(""); const [itemUnit, setItemUnit] = useState("");
const [itemStatus, setItemStatus] = useState("");
const [isSubmitted, setIsSubmitted] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (item: CreateInventoryItemInput) => createInventoryItem(item), mutationFn: (item: CreateInventoryItemInput) => createInventoryItem(item),
onSuccess: () => { onSuccess: () => {
// Invalidate queries to refresh inventory data. // invalidate queries to refresh inventory data
queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); queryClient.invalidateQueries({ queryKey: ["inventoryItems"] });
// Reset form fields and close dialog.
setItemName(""); setItemName("");
setItemType("");
setItemCategory(""); setItemCategory("");
setItemQuantity(0); setItemQuantity(0);
setItemUnit(""); setItemUnit("");
setDate(undefined); setDate(undefined);
setIsSubmitted(true);
setSuccessMessage("Item created successfully!");
// reset success message after 3 seconds
setTimeout(() => {
setIsSubmitted(false);
setSuccessMessage("");
setOpen(false); setOpen(false);
}, 3000);
},
onError: (error: any) => {
console.error("Error creating item: ", error);
setErrorMessage(
"There was an error creating the item. Please try again."
);
// reset success message after 3 seconds
setTimeout(() => {
setErrorMessage("");
}, 3000);
}, },
}); });
const inputStates = [itemName, itemCategory, itemUnit, itemStatus, date];
const isInputValid = inputStates.every((input) => input);
const handleSave = () => { const handleSave = () => {
// Basic validation (you can extend this as needed) if (!isInputValid) {
if (!itemName || !itemType || !itemCategory || !itemUnit) return; setErrorMessage(
mutation.mutate({ "There was an error creating the item. Please try again."
);
return;
}
const newItem: CreateInventoryItemInput = {
name: itemName, name: itemName,
type: itemType, categoryId:
category: itemCategory, inventoryCategory.find((item) => item.name === itemCategory)?.id || 0,
quantity: itemQuantity, quantity: itemQuantity,
unit: itemUnit, unitId: harvestUnits.find((item) => item.name === itemUnit)?.id || 0,
}); statusId:
inventoryStatus.find((item) => item.name === itemStatus)?.id || 0,
dateAdded: date ? date.toISOString() : new Date().toISOString(),
};
// console.table(newItem);
mutation.mutate(newItem);
}; };
return ( return (
<>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button>Add New Item</Button> <Button>Add New Item</Button>
@ -79,43 +129,64 @@ export function AddInventoryItem() {
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Inventory Item</DialogTitle> <DialogTitle>Add Inventory Item</DialogTitle>
<DialogDescription>Add a new plantation or fertilizer item to your inventory.</DialogDescription> <DialogDescription>
Add a new plantation or fertilizer item to your inventory.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right"> <Label htmlFor="name" className="text-right">
Name Name
</Label> </Label>
<Input id="name" className="col-span-3" value={itemName} onChange={(e) => setItemName(e.target.value)} /> <Input
id="name"
className="col-span-3"
value={itemName}
onChange={(e) => setItemName(e.target.value)}
/>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right"> <Label htmlFor="type" className="text-right">
Type Category
</Label> </Label>
<Select value={itemType} onValueChange={setItemType}> <Select value={itemCategory} onValueChange={setItemCategory}>
<SelectTrigger className="col-span-3"> <SelectTrigger className="col-span-3">
<SelectValue placeholder="Select type" /> <SelectValue placeholder="Select type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel>Type</SelectLabel> <SelectLabel>Category</SelectLabel>
<SelectItem value="plantation">Plantation</SelectItem> {inventoryCategory.map((categoryItem) => (
<SelectItem value="fertilizer">Fertilizer</SelectItem> <SelectItem
key={categoryItem.id}
value={categoryItem.name}
>
{categoryItem.name}
</SelectItem>
))}
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="category" className="text-right"> <Label htmlFor="type" className="text-right">
Category Status
</Label> </Label>
<Input <Select value={itemStatus} onValueChange={setItemStatus}>
id="category" <SelectTrigger className="col-span-3">
className="col-span-3" <SelectValue placeholder="Select status" />
placeholder="e.g., Seeds, Organic" </SelectTrigger>
value={itemCategory} <SelectContent>
onChange={(e) => setItemCategory(e.target.value)} <SelectGroup>
/> <SelectLabel>Status</SelectLabel>
{inventoryStatus.map((statusItem) => (
<SelectItem key={statusItem.id} value={statusItem.name}>
{statusItem.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="quantity" className="text-right"> <Label htmlFor="quantity" className="text-right">
@ -125,21 +196,29 @@ export function AddInventoryItem() {
id="quantity" id="quantity"
type="number" type="number"
className="col-span-3" className="col-span-3"
value={itemQuantity} value={itemQuantity === 0 ? "" : itemQuantity}
onChange={(e) => setItemQuantity(Number(e.target.value))} onChange={(e) => setItemQuantity(Number(e.target.value))}
/> />
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="unit" className="text-right"> <Label htmlFor="type" className="text-right">
Unit Unit
</Label> </Label>
<Input <Select value={itemUnit} onValueChange={setItemUnit}>
id="unit" <SelectTrigger className="col-span-3">
className="col-span-3" <SelectValue placeholder="Select status" />
placeholder="e.g., kg, packets" </SelectTrigger>
value={itemUnit} <SelectContent>
onChange={(e) => setItemUnit(e.target.value)} <SelectGroup>
/> <SelectLabel>Unit</SelectLabel>
{harvestUnits.map((unit) => (
<SelectItem key={unit.id} value={unit.name}>
{unit.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="date" className="text-right"> <Label htmlFor="date" className="text-right">
@ -149,23 +228,47 @@ export function AddInventoryItem() {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant={"outline"} variant={"outline"}
className={cn("col-span-3 justify-start text-left font-normal", !date && "text-muted-foreground")}> className={cn(
"col-span-3 justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP") : "Pick a date"} {date ? format(date, "PPP") : "Pick a date"}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0"> <PopoverContent className="w-auto p-0">
<Calendar mode="single" selected={date} onSelect={setDate} initialFocus /> <Calendar
mode="single"
selected={date}
onSelect={setDate}
initialFocus
/>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={handleSave}> <div className="flex flex-col items-center w-full space-y-2">
<Button type="button" onClick={handleSave} className="w-full">
Save Item Save Item
</Button> </Button>
<div className="flex flex-col items-center space-y-2">
{isSubmitted && (
<p className="text-green-500 text-sm">
{successMessage} You may close this window.
</p>
)}
{errorMessage && (
<p className="text-red-500 text-sm">{errorMessage}</p>
)}
</div>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
); );
} }

View File

@ -1,63 +1,69 @@
"use client";
import { useState } from "react"; 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 { import {
Dialog, Dialog,
DialogTrigger,
DialogContent, DialogContent,
DialogTitle,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { import { deleteInventoryItem } from "@/api/inventory";
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() { export function DeleteInventoryItem({ id }: { id: string }) {
const [date, setDate] = useState<Date | undefined>();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [itemName, setItemName] = useState(""); const queryClient = useQueryClient();
const [itemType, setItemType] = useState("");
const [itemCategory, setItemCategory] = useState("");
const [itemQuantity, setItemQuantity] = useState(0);
const [itemUnit, setItemUnit] = useState("");
// const queryClient = useQueryClient(); const { mutate: deleteItem, status } = useMutation({
mutationFn: deleteInventoryItem,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["inventoryItems"] });
},
onError: (error) => {
console.error("Failed to delete item:", error);
},
});
const handleDelete = () => { const handleDelete = () => {
// handle delete item deleteItem(id.toString());
}; };
return ( return (
<div>
{/* delete confirmation dialog */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button <Button
type="submit" type="button"
className="bg-red-500 hover:bg-red-800 text-white" className="bg-red-500 hover:bg-red-800 text-white"
onClick={handleDelete}
> >
Delete Item Delete Item
</Button> </Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogTitle>Confirm Deletion</DialogTitle>
<DialogDescription>
Are you sure you want to delete this item? This action cannot be
undone.
</DialogDescription>
<DialogFooter>
<Button
className="bg-gray-500 hover:bg-gray-700 text-white"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
className="bg-red-600 hover:bg-red-800 text-white"
onClick={handleDelete}
disabled={status === "pending"}
>
{status === "pending" ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
); );
} }

View File

@ -1,12 +1,8 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,11 +14,6 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -32,65 +23,93 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import {
// import { updateInventoryItem } from "@/api/inventory"; InventoryStatus,
// import type { UpdateInventoryItemInput } from "@/types"; InventoryItemCategory,
HarvestUnits,
export interface EditInventoryItemProps { UpdateInventoryItemInput,
id: string; EditInventoryItemInput,
name: string; } from "@/types";
category: string; import { updateInventoryItem } from "@/api/inventory";
status: string;
type: string;
unit: string;
quantity: number;
}
export function EditInventoryItem({ export function EditInventoryItem({
id, item,
name, fetchedInventoryStatus,
category, fetchedInventoryCategory,
status, fetchedHarvestUnits,
type, }: {
unit, item: UpdateInventoryItemInput;
quantity, fetchedInventoryStatus: InventoryStatus[];
}: EditInventoryItemProps) { fetchedInventoryCategory: InventoryItemCategory[];
fetchedHarvestUnits: HarvestUnits[];
}) {
// console.table(item);
// console.log(item.id);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [itemName, setItemName] = useState(name); const [itemName, setItemName] = useState(item.name);
const [itemType, setItemType] = useState(type); const [itemCategory, setItemCategory] = useState(
const [itemCategory, setItemCategory] = useState(category); fetchedInventoryCategory.find((x) => x.id === item.categoryId)?.name
const [itemQuantity, setItemQuantity] = useState(quantity); );
const [itemUnit, setItemUnit] = useState(unit);
const [itemStatus, setItemStatus] = useState(status);
// const queryClient = useQueryClient(); const [itemQuantity, setItemQuantity] = useState(item.quantity);
// const mutation = useMutation({ const [itemUnit, setItemUnit] = useState(
// mutationFn: (item: UpdateInventoryItemInput) => UpdateInventoryItem(item), fetchedHarvestUnits.find((x) => x.id === item.unitId)?.name
// 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 [itemStatus, setItemStatus] = useState(
fetchedInventoryStatus.find((x) => x.id === item.statusId)?.name
);
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (x: EditInventoryItemInput) => updateInventoryItem(item.id, x),
onSuccess: () => {
// invalidate queries to refresh inventory data.
queryClient.invalidateQueries({ queryKey: ["inventoryItems"] });
// reset form fields and close dialog.
setItemName("");
setItemCategory("");
setItemQuantity(0);
setItemUnit("");
setOpen(false);
setItemStatus("");
},
});
// send edit request
const handleEdit = () => { const handleEdit = () => {
// // Basic validation (you can extend this as needed) if (!itemName || !itemCategory || !itemUnit) {
// if (!itemName || !itemType || !itemCategory || !itemUnit) return; setError("All fields are required. Please fill in missing details.");
// mutation.mutate({ return;
// name: itemName, }
// type: itemType,
// category: itemCategory, const category = fetchedInventoryCategory.find(
// quantity: itemQuantity, (c) => c.name === itemCategory
// unit: itemUnit, )?.id;
// }); const unit = fetchedHarvestUnits.find((u) => u.name === itemUnit)?.id;
const status = fetchedInventoryStatus.find(
(s) => s.name === itemStatus
)?.id;
if (!category || !unit || !status) {
setError(
"Invalid category, unit, or status. Please select a valid option."
);
return;
}
// console.log("Mutate called");
// console.log(item.id);
mutation.mutate({
name: itemName,
categoryId: category,
quantity: itemQuantity ?? 0,
unitId: unit,
statusId: status,
dateAdded: new Date().toISOString(),
});
}; };
return ( return (
@ -119,17 +138,20 @@ export function EditInventoryItem({
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right"> <Label htmlFor="type" className="text-right">
Type Category
</Label> </Label>
<Select value={itemType.toLowerCase()} onValueChange={setItemType}> <Select value={itemCategory} onValueChange={setItemCategory}>
<SelectTrigger className="col-span-3"> <SelectTrigger className="col-span-3">
<SelectValue placeholder="Select type" /> <SelectValue placeholder="Select type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel>Type</SelectLabel> <SelectLabel>Category</SelectLabel>
<SelectItem value="plantation">Plantation</SelectItem> {fetchedInventoryCategory.map((categoryItem, _) => (
<SelectItem value="fertilizer">Fertilizer</SelectItem> <SelectItem key={categoryItem.id} value={categoryItem.name}>
{categoryItem.name}
</SelectItem>
))}
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@ -138,35 +160,22 @@ export function EditInventoryItem({
<Label htmlFor="type" className="text-right"> <Label htmlFor="type" className="text-right">
Status Status
</Label> </Label>
<Select <Select value={itemStatus} onValueChange={setItemStatus}>
value={itemStatus.toLowerCase()}
onValueChange={setItemStatus}
>
<SelectTrigger className="col-span-3"> <SelectTrigger className="col-span-3">
<SelectValue placeholder="Select status" /> <SelectValue placeholder="Select status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel>Status</SelectLabel> <SelectLabel>Status</SelectLabel>
<SelectItem value="in stock">In Stock</SelectItem> {fetchedInventoryStatus.map((statusItem, _) => (
<SelectItem value="low stock">Low Stock</SelectItem> <SelectItem key={statusItem.id} value={statusItem.name}>
<SelectItem value="out of stock">Out Of Stock</SelectItem> {statusItem.name}
</SelectItem>
))}
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="quantity" className="text-right"> <Label htmlFor="quantity" className="text-right">
Quantity Quantity
@ -180,21 +189,30 @@ export function EditInventoryItem({
/> />
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="unit" className="text-right"> <Label htmlFor="type" className="text-right">
Unit Unit
</Label> </Label>
<Input <Select value={itemUnit} onValueChange={setItemUnit}>
id="unit" <SelectTrigger className="col-span-3">
className="col-span-3" <SelectValue placeholder="Select status" />
placeholder="e.g., kg, packets" </SelectTrigger>
value={itemUnit} <SelectContent>
onChange={(e) => setItemUnit(e.target.value)} <SelectGroup>
/> <SelectLabel>Unit</SelectLabel>
{fetchedHarvestUnits.map((unit, _) => (
<SelectItem key={unit.id} value={unit.name}>
{unit.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
{error && <p className="text-red-500 text-sm">{error}</p>}
<Button type="submit" onClick={handleEdit}> <Button type="submit" onClick={handleEdit}>
Edit Item Save
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -22,22 +22,27 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { TriangleAlertIcon } from "lucide-react";
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
PaginationItem, PaginationItem,
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa"; import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
import { fetchHarvestUnits } from "@/api/harvest";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { fetchInventoryItems, fetchInventoryStatus } from "@/api/inventory";
import { AddInventoryItem } from "./add-inventory-item";
import { import {
EditInventoryItem, fetchInventoryItems,
EditInventoryItemProps, fetchInventoryStatus,
} from "./edit-inventory-item"; fetchInventoryCategory,
} from "@/api/inventory";
import { AddInventoryItem } from "./add-inventory-item";
import { EditInventoryItem } from "./edit-inventory-item";
import { DeleteInventoryItem } from "./delete-inventory-item"; import { DeleteInventoryItem } from "./delete-inventory-item";
import { InventoryItem } from "@/types";
export default function InventoryPage() { export default function InventoryPage() {
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@ -45,7 +50,8 @@ export default function InventoryPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}); });
//////////////////////////////
// query the necessary data for edit and etc.
const { const {
data: inventoryItems = [], data: inventoryItems = [],
isLoading: isItemLoading, isLoading: isItemLoading,
@ -55,6 +61,8 @@ export default function InventoryPage() {
queryFn: fetchInventoryItems, queryFn: fetchInventoryItems,
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
// console.table(inventoryItems);
// console.log(inventoryItems);
const { const {
data: inventoryStatus = [], data: inventoryStatus = [],
@ -65,38 +73,98 @@ export default function InventoryPage() {
queryFn: fetchInventoryStatus, queryFn: fetchInventoryStatus,
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
// console.log(inventoryStatus);
const {
data: inventoryCategory = [],
isLoading: isLoadingCategory,
isError: isErrorCategory,
} = useQuery({
queryKey: ["inventoryCategory"],
queryFn: fetchInventoryCategory,
staleTime: 60 * 1000,
});
const {
data: harvestUnits = [],
isLoading: isLoadingHarvestUnits,
isError: isErrorHarvestUnits,
} = useQuery({
queryKey: ["harvestUnits"],
queryFn: fetchHarvestUnits,
staleTime: 60 * 1000,
});
//////////////////////////////
// console.table(inventoryItems); // console.table(inventoryItems);
console.table(inventoryStatus); // console.table(inventoryStatus);
// console.table(harvestUnits);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
return inventoryItems return inventoryItems
.map((item) => ({ .map((item) => ({
...item, ...item,
id: String(item.id), // Convert `id` to string here status: { id: item.status.id, name: item.status.name },
category: { id: item.category.id, name: item.category.name },
unit: { id: item.unit.id, name: item.unit.name },
fetchedInventoryStatus: inventoryStatus,
fetchedInventoryCategory: inventoryCategory,
fetchedHarvestUnits: harvestUnits,
lastUpdated: new Date(item.updatedAt).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: true,
}),
})) }))
.filter((item) => .filter((item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) item.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
}, [inventoryItems, searchTerm]); }, [inventoryItems, searchTerm]);
// prepare columns for table
const columns = [ const columns = [
{ accessorKey: "name", header: "Name" }, { accessorKey: "name", header: "Name" },
{ accessorKey: "category", header: "Category" }, {
{ accessorKey: "quantity", header: "Quantity" }, accessorKey: "category",
{ accessorKey: "unit", header: "Unit" }, header: "Category",
{ accessorKey: "lastUpdated", header: "Last Updated" }, cell: ({ row }: { row: { original: InventoryItem } }) =>
row.original.category.name,
},
{
accessorKey: "quantity",
header: "Quantity",
},
{
accessorKey: "unit",
header: "Unit",
cell: ({ row }: { row: { original: InventoryItem } }) =>
row.original.unit.name,
},
{
accessorKey: "lastUpdated",
header: "Last Updated",
},
{ {
accessorKey: "status", accessorKey: "status",
header: "Status", header: "Status",
cell: (info: { getValue: () => string }) => { cell: ({ row }: { row: { original: InventoryItem } }) => {
const status = info.getValue(); const status = row.original.status.name;
let statusClass = ""; // default status class let statusClass = "";
if (status === "Low Stock") { if (status === "In Stock") {
statusClass = "bg-yellow-300"; // yellow for low stock statusClass = "bg-green-500 hover:bg-green-600 text-white";
} else if (status === "Out Of Stock") { } else if (status === "Low Stock") {
statusClass = "bg-red-500 text-white"; // red for out of stock statusClass = "bg-yellow-300 hover:bg-yellow-400";
} else if (status === "Out of Stock") {
statusClass = "bg-red-500 hover:bg-red-600 text-white";
} else if (status === "Expired") {
statusClass = "bg-gray-500 hover:bg-gray-600 text-white";
} else if (status === "Reserved") {
statusClass = "bg-blue-500 hover:bg-blue-600 text-white";
} }
return ( return (
@ -109,15 +177,30 @@ export default function InventoryPage() {
{ {
accessorKey: "edit", accessorKey: "edit",
header: "Edit", header: "Edit",
cell: ({ row }: { row: { original: EditInventoryItemProps } }) => ( cell: ({ row }: { row: { original: InventoryItem } }) => (
<EditInventoryItem {...row.original} /> <EditInventoryItem
item={{
id: row.original.id,
name: row.original.name,
categoryId: row.original.category.id,
quantity: row.original.quantity,
unitId: row.original.unit.id,
dateAdded: row.original.dateAdded,
statusId: row.original.status.id,
}}
fetchedInventoryStatus={inventoryStatus}
fetchedInventoryCategory={inventoryCategory}
fetchedHarvestUnits={harvestUnits}
/>
), ),
enableSorting: false, enableSorting: false,
}, },
{ {
accessorKey: "delete", accessorKey: "delete",
header: "Delete", header: "Delete",
cell: () => <DeleteInventoryItem />, cell: ({ row }: { row: { original: InventoryItem } }) => (
<DeleteInventoryItem id={row.original.id} />
),
enableSorting: false, enableSorting: false,
}, },
]; ];
@ -132,20 +215,61 @@ export default function InventoryPage() {
onSortingChange: setSorting, onSortingChange: setSorting,
onPaginationChange: setPagination, onPaginationChange: setPagination,
}); });
const loadingStates = [
isItemLoading,
isLoadingStatus,
isLoadingCategory,
isLoadingHarvestUnits,
];
const errorStates = [
isItemError,
isErrorStatus,
isErrorCategory,
isErrorHarvestUnits,
];
if (isItemLoading || isLoadingStatus) const isLoading = loadingStates.some((loading) => loading);
const isError = errorStates.some((error) => error);
if (isLoading)
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center">
Loading... Loading...
</div> </div>
); );
if (isItemError || isErrorStatus)
if (isError)
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center">
Error loading inventory data. Error loading inventory data.
</div> </div>
); );
if (inventoryItems.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-[50vh]">
<Alert variant="destructive" className="w-full max-w-md text-center">
<div className="flex flex-col items-center">
<TriangleAlertIcon className="h-6 w-6 text-red-500 mb-2" />
<AlertTitle>No Inventory Data</AlertTitle>
<AlertDescription>
<div>
You currently have no inventory items. Add a new item to get
started!
</div>
<div className="mt-5">
<AddInventoryItem
inventoryCategory={inventoryCategory}
inventoryStatus={inventoryStatus}
harvestUnits={harvestUnits}
/>
</div>
</AlertDescription>
</div>
</Alert>
</div>
);
}
return ( return (
<div className="flex min-h-screen bg-background"> <div className="flex min-h-screen bg-background">
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
@ -159,7 +283,11 @@ export default function InventoryPage() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
<AddInventoryItem /> <AddInventoryItem
inventoryCategory={inventoryCategory}
inventoryStatus={inventoryStatus}
harvestUnits={harvestUnits}
/>
</div> </div>
<div className="border rounded-md"> <div className="border rounded-md">
<Table> <Table>

View File

@ -14,9 +14,18 @@ import type { z } from "zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { registerUser } from "@/api/authentication"; import { registerUser } from "@/api/authentication";
import { SessionContext } from "@/context/SessionContext"; import { SessionContext } from "@/context/SessionContext";
import { Eye, EyeOff, Leaf, ArrowRight, AlertCircle, Loader2, Check } from "lucide-react"; import {
Eye,
EyeOff,
Leaf,
ArrowRight,
AlertCircle,
Loader2,
Check,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { GoogleSigninButton } from "../signin/google-oauth";
export default function SignupPage() { export default function SignupPage() {
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
@ -75,7 +84,9 @@ export default function SignupPage() {
const data = await registerUser(values.email, values.password); const data = await registerUser(values.email, values.password);
if (!data) { if (!data) {
setServerError("An error occurred while registering. Please try again."); setServerError(
"An error occurred while registering. Please try again."
);
throw new Error("No data received from the server."); throw new Error("No data received from the server.");
} }
@ -120,9 +131,12 @@ export default function SignupPage() {
</Link> </Link>
</div> </div>
<div className="max-w-md"> <div className="max-w-md">
<h2 className="text-3xl font-bold text-white mb-4">Join the farming revolution</h2> <h2 className="text-3xl font-bold text-white mb-4">
Join the farming revolution
</h2>
<p className="text-green-100 mb-6"> <p className="text-green-100 mb-6">
Create your account today and discover how ForFarm can help you optimize your agricultural operations. Create your account today and discover how ForFarm can help
you optimize your agricultural operations.
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
{[ {[
@ -148,11 +162,18 @@ export default function SignupPage() {
<div className="flex justify-center items-center p-6"> <div className="flex justify-center items-center p-6">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Theme Selector Placeholder */} {/* Theme Selector Placeholder */}
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">Theme Selector Placeholder</div> <div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
Theme Selector Placeholder
</div>
<div className="lg:hidden flex justify-center mb-8"> <div className="lg:hidden flex justify-center mb-8">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<Image src="/forfarm-logo.png" alt="Forfarm" width={80} height={80} /> <Image
src="/forfarm-logo.png"
alt="Forfarm"
width={80}
height={80}
/>
</Link> </Link>
</div> </div>
@ -160,7 +181,10 @@ export default function SignupPage() {
<h1 className="text-3xl font-bold mb-2">Create your account</h1> <h1 className="text-3xl font-bold mb-2">Create your account</h1>
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
Already have an account?{" "} Already have an account?{" "}
<Link href="/auth/signin" className="text-green-600 hover:text-green-700 font-medium"> <Link
href="/auth/signin"
className="text-green-600 hover:text-green-700 font-medium"
>
Sign in Sign in
</Link> </Link>
</p> </p>
@ -184,7 +208,10 @@ export default function SignupPage() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* Email */} {/* Email */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium dark:text-gray-300"> <Label
htmlFor="email"
className="text-sm font-medium dark:text-gray-300"
>
Email Email
</Label> </Label>
<div className="relative"> <div className="relative">
@ -192,7 +219,11 @@ export default function SignupPage() {
type="email" type="email"
id="email" id="email"
placeholder="name@example.com" placeholder="name@example.com"
className={`h-12 px-4 ${errors.email ? "border-red-500 focus-visible:ring-red-500" : ""}`} className={`h-12 px-4 ${
errors.email
? "border-red-500 focus-visible:ring-red-500"
: ""
}`}
{...register("email")} {...register("email")}
/> />
</div> </div>
@ -206,7 +237,10 @@ export default function SignupPage() {
{/* Password */} {/* Password */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium dark:text-gray-300"> <Label
htmlFor="password"
className="text-sm font-medium dark:text-gray-300"
>
Password Password
</Label> </Label>
<div className="relative"> <div className="relative">
@ -214,15 +248,26 @@ export default function SignupPage() {
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
id="password" id="password"
placeholder="••••••••" placeholder="••••••••"
className={`h-12 px-4 ${errors.password ? "border-red-500 focus-visible:ring-red-500" : ""}`} className={`h-12 px-4 ${
errors.password
? "border-red-500 focus-visible:ring-red-500"
: ""
}`}
{...register("password", { onChange: onPasswordChange })} {...register("password", { onChange: onPasswordChange })}
/> />
<button <button
type="button" type="button"
aria-label={showPassword ? "Hide password" : "Show password"} aria-label={
showPassword ? "Hide password" : "Show password"
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
onClick={() => setShowPassword(!showPassword)}> onClick={() => setShowPassword(!showPassword)}
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />} >
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button> </button>
</div> </div>
@ -230,7 +275,9 @@ export default function SignupPage() {
{password && ( {password && (
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs text-gray-500 dark:text-gray-400">Password strength</span> <span className="text-xs text-gray-500 dark:text-gray-400">
Password strength
</span>
<span <span
className={`text-xs font-medium ${ className={`text-xs font-medium ${
passwordStrength <= 25 passwordStrength <= 25
@ -240,11 +287,15 @@ export default function SignupPage() {
: passwordStrength <= 75 : passwordStrength <= 75
? "text-blue-500" ? "text-blue-500"
: "text-green-500" : "text-green-500"
}`}> }`}
>
{getPasswordStrengthText()} {getPasswordStrengthText()}
</span> </span>
</div> </div>
<Progress value={passwordStrength} className={`${getPasswordStrengthColor()} h-1`} /> <Progress
value={passwordStrength}
className={`${getPasswordStrengthColor()} h-1`}
/>
</div> </div>
)} )}
@ -258,7 +309,10 @@ export default function SignupPage() {
{/* Confirm Password */} {/* Confirm Password */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium dark:text-gray-300"> <Label
htmlFor="confirmPassword"
className="text-sm font-medium dark:text-gray-300"
>
Confirm Password Confirm Password
</Label> </Label>
<div className="relative"> <div className="relative">
@ -266,15 +320,26 @@ export default function SignupPage() {
type={showConfirmPassword ? "text" : "password"} type={showConfirmPassword ? "text" : "password"}
id="confirmPassword" id="confirmPassword"
placeholder="••••••••" placeholder="••••••••"
className={`h-12 px-4 ${errors.confirmPassword ? "border-red-500 focus-visible:ring-red-500" : ""}`} className={`h-12 px-4 ${
errors.confirmPassword
? "border-red-500 focus-visible:ring-red-500"
: ""
}`}
{...register("confirmPassword")} {...register("confirmPassword")}
/> />
<button <button
type="button" type="button"
aria-label={showConfirmPassword ? "Hide password" : "Show password"} aria-label={
showConfirmPassword ? "Hide password" : "Show password"
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}> onClick={() => setShowConfirmPassword(!showConfirmPassword)}
{showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />} >
{showConfirmPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button> </button>
</div> </div>
{errors.confirmPassword && ( {errors.confirmPassword && (
@ -288,7 +353,8 @@ export default function SignupPage() {
<Button <Button
type="submit" type="submit"
className="w-full h-12 rounded-full font-medium text-base bg-green-600 hover:bg-green-700 transition-all" className="w-full h-12 rounded-full font-medium text-base bg-green-600 hover:bg-green-700 transition-all"
disabled={isLoading}> disabled={isLoading}
>
{isLoading ? ( {isLoading ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@ -316,22 +382,23 @@ export default function SignupPage() {
</div> </div>
<div className="mt-6"> <div className="mt-6">
<Button <GoogleSigninButton />
variant="outline"
className="w-full h-12 rounded-full border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<Image src="/google-logo.png" alt="Google Logo" width={20} height={20} className="mr-2" />
Sign up with Google
</Button>
</div> </div>
</div> </div>
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-8"> <p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-8">
By signing up, you agree to our{" "} By signing up, you agree to our{" "}
<Link href="/terms" className="text-green-600 hover:text-green-700"> <Link
href="/terms"
className="text-green-600 hover:text-green-700"
>
Terms of Service Terms of Service
</Link>{" "} </Link>{" "}
and{" "} and{" "}
<Link href="/privacy" className="text-green-600 hover:text-green-700"> <Link
href="/privacy"
className="text-green-600 hover:text-green-700"
>
Privacy Policy Privacy Policy
</Link> </Link>
</p> </p>

File diff suppressed because it is too large Load Diff

View File

@ -142,15 +142,30 @@ export interface HarvestUnit {
name: string; name: string;
} }
export interface CreateInventoryItemInput { export type InventoryItemCategory = {
id: number;
name: string;
};
export type HarvestUnits = {
id: number;
name: string;
};
export type CreateInventoryItemInput = {
name: string; name: string;
categoryId: number; categoryId: number;
quantity: number; quantity: number;
unitId: number; unitId: number;
dateAdded: string; dateAdded: string;
statusId: number; statusId: number;
} };
export type UpdateInventoryItemInput = Partial<CreateInventoryItemInput> & { id: string };
// export type UpdateInventoryItemInput = CreateInventoryItemInput & {};
export type EditInventoryItemInput = CreateInventoryItemInput;
export type UpdateInventoryItemInput = Partial<CreateInventoryItemInput> & {
id: string;
};
export interface Blog { export interface Blog {
id: number; id: number;
@ -227,22 +242,32 @@ export interface SetOverlayAction {
export type Action = ActionWithTypeOnly | SetOverlayAction; export type Action = ActionWithTypeOnly | SetOverlayAction;
export function isCircle(overlay: OverlayGeometry): overlay is google.maps.Circle { export function isCircle(
overlay: OverlayGeometry
): overlay is google.maps.Circle {
return (overlay as google.maps.Circle).getCenter !== undefined; return (overlay as google.maps.Circle).getCenter !== undefined;
} }
export function isMarker(overlay: OverlayGeometry): overlay is google.maps.Marker { export function isMarker(
overlay: OverlayGeometry
): overlay is google.maps.Marker {
return (overlay as google.maps.Marker).getPosition !== undefined; return (overlay as google.maps.Marker).getPosition !== undefined;
} }
export function isPolygon(overlay: OverlayGeometry): overlay is google.maps.Polygon { export function isPolygon(
overlay: OverlayGeometry
): overlay is google.maps.Polygon {
return (overlay as google.maps.Polygon).getPath !== undefined; return (overlay as google.maps.Polygon).getPath !== undefined;
} }
export function isPolyline(overlay: OverlayGeometry): overlay is google.maps.Polyline { export function isPolyline(
overlay: OverlayGeometry
): overlay is google.maps.Polyline {
return (overlay as google.maps.Polyline).getPath !== undefined; return (overlay as google.maps.Polyline).getPath !== undefined;
} }
export function isRectangle(overlay: OverlayGeometry): overlay is google.maps.Rectangle { export function isRectangle(
overlay: OverlayGeometry
): overlay is google.maps.Rectangle {
return (overlay as google.maps.Rectangle).getBounds !== undefined; return (overlay as google.maps.Rectangle).getBounds !== undefined;
} }