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

@ -30,13 +30,14 @@ type api struct {
httpClient *http.Client
eventPublisher domain.EventPublisher
userRepo domain.UserRepository
cropRepo domain.CroplandRepository
farmRepo domain.FarmRepository
plantRepo domain.PlantRepository
inventoryRepo domain.InventoryRepository
harvestRepo domain.HarvestRepository
analyticsRepo domain.AnalyticsRepository
userRepo domain.UserRepository
cropRepo domain.CroplandRepository
farmRepo domain.FarmRepository
plantRepo domain.PlantRepository
inventoryRepo domain.InventoryRepository
harvestRepo domain.HarvestRepository
analyticsRepo domain.AnalyticsRepository
knowledgeHubRepo domain.KnowledgeHubRepository
weatherFetcher domain.WeatherFetcher
@ -61,8 +62,9 @@ func NewAPI(
client := &http.Client{}
userRepository := repository.NewPostgresUser(pool)
harvestRepository := repository.NewPostgresHarvest(pool)
plantRepository := repository.NewPostgresPlant(pool)
knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool)
harvestRepository := repository.NewPostgresHarvest(pool)
owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger)
cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL)
@ -87,15 +89,15 @@ func NewAPI(
httpClient: client,
eventPublisher: eventPublisher,
userRepo: userRepository,
cropRepo: croplandRepo,
farmRepo: farmRepo,
plantRepo: plantRepository,
inventoryRepo: inventoryRepo,
harvestRepo: harvestRepository,
analyticsRepo: analyticsRepo,
weatherFetcher: cachedWeatherFetcher,
userRepo: userRepository,
cropRepo: croplandRepo,
farmRepo: farmRepo,
plantRepo: plantRepository,
inventoryRepo: inventoryRepo,
harvestRepo: harvestRepository,
analyticsRepo: analyticsRepo,
knowledgeHubRepo: knowledgeHubRepository,
weatherFetcher: cachedWeatherFetcher,
chatService: chatService,
}
@ -138,6 +140,7 @@ func (a *api) Routes() *chi.Mux {
a.registerAuthRoutes(r, api)
a.registerCropRoutes(r, api)
a.registerPlantRoutes(r, api)
a.registerKnowledgeHubRoutes(r, api)
a.registerOauthRoutes(r, api)
a.registerChatRoutes(r, api)
a.registerInventoryRoutes(r, api)

View File

@ -77,10 +77,10 @@ type InventoryItemResponse struct {
Category InventoryCategory `json:"category"`
Quantity float64 `json:"quantity"`
Unit HarvestUnit `json:"unit"`
DateAdded time.Time `json:"date_added"`
DateAdded time.Time `json:"dateAdded"`
Status InventoryStatus `json:"status"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}
type InventoryStatus struct {
@ -100,14 +100,13 @@ type HarvestUnit struct {
type CreateInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
Body struct {
Name string `json:"name" required:"true"`
CategoryID int `json:"category_id" required:"true"`
CategoryID int `json:"categoryId" required:"true"`
Quantity float64 `json:"quantity" required:"true"`
UnitID int `json:"unit_id" required:"true"`
DateAdded time.Time `json:"date_added" required:"true"`
StatusID int `json:"status_id" required:"true"`
UnitID int `json:"unitId" required:"true"`
DateAdded time.Time `json:"dateAdded" required:"true"`
StatusID int `json:"statusId" required:"true"`
}
}
@ -119,15 +118,14 @@ type CreateInventoryItemOutput struct {
type UpdateInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
ID string `path:"id"`
Body struct {
Name string `json:"name"`
CategoryID int `json:"category_id"`
CategoryID int `json:"categoryId"`
Quantity float64 `json:"quantity"`
UnitID int `json:"unit_id"`
DateAdded time.Time `json:"date_added"`
StatusID int `json:"status_id"`
UnitID int `json:"unitId"`
DateAdded time.Time `json:"dateAdded"`
StatusID int `json:"statusId"`
}
}
@ -137,14 +135,13 @@ type UpdateInventoryItemOutput struct {
type GetInventoryItemsInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
CategoryID int `query:"category_id"`
StatusID int `query:"status_id"`
StartDate time.Time `query:"start_date" format:"date-time"`
EndDate time.Time `query:"end_date" format:"date-time"`
CategoryID int `query:"categoryId"`
StatusID int `query:"statusId"`
StartDate time.Time `query:"startDate" format:"date-time"`
EndDate time.Time `query:"endDate" format:"date-time"`
SearchQuery string `query:"search"`
SortBy string `query:"sort_by" enum:"name,quantity,date_added,created_at"`
SortOrder string `query:"sort_order" enum:"asc,desc" default:"desc"`
SortBy string `query:"sortBy" enum:"name,quantity,dateAdded,createdAt"`
SortOrder string `query:"sortOrder" enum:"asc,desc" default:"desc"`
}
type GetInventoryItemsOutput struct {
@ -153,7 +150,7 @@ type GetInventoryItemsOutput struct {
type GetInventoryItemInput struct {
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"`
}
@ -163,7 +160,6 @@ type GetInventoryItemOutput struct {
type DeleteInventoryItemInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UserID string `header:"user_id" required:"true" example:"user-uuid"`
ID string `path:"id"`
}
@ -186,8 +182,9 @@ type GetHarvestUnitsOutput struct {
}
func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInventoryItemInput) (*CreateInventoryItemOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header)
item := &domain.InventoryItem{
UserID: input.UserID,
UserID: userID,
Name: input.Body.Name,
CategoryID: input.Body.CategoryID,
Quantity: input.Body.Quantity,
@ -200,7 +197,7 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven
return nil, huma.Error422UnprocessableEntity(err.Error())
}
err := a.inventoryRepo.CreateOrUpdate(ctx, item)
err = a.inventoryRepo.CreateOrUpdate(ctx, item)
if err != nil {
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) {
userID, err := a.getUserIDFromHeader(input.Header)
filter := domain.InventoryFilter{
UserID: input.UserID,
UserID: userID,
CategoryID: input.CategoryID,
StatusID: input.StatusID,
StartDate: input.StartDate,
@ -225,7 +223,7 @@ func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInve
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 {
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) {
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 {
return nil, err
}
@ -319,7 +318,7 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven
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 {
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) {
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 {
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)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
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,20 +1,23 @@
import axiosInstance from "./config";
import type {
InventoryItem,
InventoryStatus,
InventoryItemCategory,
CreateInventoryItemInput,
InventoryItemStatus,
EditInventoryItemInput,
} from "@/types";
import { AxiosError } from "axios";
/**
* Simulates an API call to fetch inventory items.
* Waits for a simulated delay and then attempts an axios GET request.
* If the request fails, returns fallback dummy data.
*
*
*
*
*/
export async function fetchInventoryStatus(): Promise<InventoryItemStatus[]> {
export async function fetchInventoryStatus(): Promise<InventoryStatus[]> {
try {
const response = await axiosInstance.get<InventoryItemStatus[]>(
const response = await axiosInstance.get<InventoryStatus[]>(
"/inventory/status"
);
return response.data;
@ -23,96 +26,136 @@ export async function fetchInventoryStatus(): Promise<InventoryItemStatus[]> {
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[]> {
try {
const response = await axiosInstance.get<InventoryItem[]>("/api/inventory");
const response = await axiosInstance.get<InventoryItem[]>("/inventory");
return response.data;
} catch (error) {
// Fallback dummy data
return [
{
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",
},
];
console.error("Error while fetching inventory items! " + error);
throw error;
}
}
/**
* 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(
item: Omit<InventoryItem, "id" | "lastUpdated" | "status">
item: Omit<CreateInventoryItemInput, "id" | "lastUpdated" | "status">
): Promise<InventoryItem> {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
try {
const response = await axiosInstance.post<InventoryItem>(
"/api/inventory",
"/inventory",
item
);
return response.data;
} catch (error) {
// Simulate successful creation if API endpoint is not available
return {
id: Math.floor(Math.random() * 1000),
name: item.name,
category: item.category,
type: item.type,
quantity: item.quantity,
unit: item.unit,
lastUpdated: new Date().toISOString(),
status: "In Stock",
};
} 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 creating 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 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 { format } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
@ -18,7 +17,11 @@ import {
} 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 {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
@ -30,142 +33,242 @@ import {
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
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 [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 [itemStatus, setItemStatus] = useState("");
const [isSubmitted, setIsSubmitted] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (item: CreateInventoryItemInput) => createInventoryItem(item),
onSuccess: () => {
// Invalidate queries to refresh inventory data.
// 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);
setIsSubmitted(true);
setSuccessMessage("Item created successfully!");
// reset success message after 3 seconds
setTimeout(() => {
setIsSubmitted(false);
setSuccessMessage("");
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 = () => {
// Basic validation (you can extend this as needed)
if (!itemName || !itemType || !itemCategory || !itemUnit) return;
mutation.mutate({
if (!isInputValid) {
setErrorMessage(
"There was an error creating the item. Please try again."
);
return;
}
const newItem: CreateInventoryItemInput = {
name: itemName,
type: itemType,
category: itemCategory,
categoryId:
inventoryCategory.find((item) => item.name === itemCategory)?.id || 0,
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Add New Item</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Inventory Item</DialogTitle>
<DialogDescription>Add a new plantation or fertilizer item to 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)} />
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Add New Item</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Inventory Item</DialogTitle>
<DialogDescription>
Add a new plantation or fertilizer item to 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">
Category
</Label>
<Select value={itemCategory} onValueChange={setItemCategory}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Category</SelectLabel>
{inventoryCategory.map((categoryItem) => (
<SelectItem
key={categoryItem.id}
value={categoryItem.name}
>
{categoryItem.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">
Status
</Label>
<Select value={itemStatus} onValueChange={setItemStatus}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Status</SelectLabel>
{inventoryStatus.map((statusItem) => (
<SelectItem key={statusItem.id} value={statusItem.name}>
{statusItem.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</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 === 0 ? "" : itemQuantity}
onChange={(e) => setItemQuantity(Number(e.target.value))}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">
Unit
</Label>
<Select value={itemUnit} onValueChange={setItemUnit}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Unit</SelectLabel>
{harvestUnits.map((unit) => (
<SelectItem key={unit.id} value={unit.name}>
{unit.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</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>
<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={handleSave}>
Save Item
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter>
<div className="flex flex-col items-center w-full space-y-2">
<Button type="button" onClick={handleSave} className="w-full">
Save Item
</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>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,63 +1,69 @@
"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,
DialogTrigger,
DialogContent,
DialogTitle,
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";
import { Button } from "@/components/ui/button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteInventoryItem } from "@/api/inventory";
export function DeleteInventoryItem() {
const [date, setDate] = useState<Date | undefined>();
export function DeleteInventoryItem({ id }: { id: string }) {
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 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 = () => {
// handle delete item
deleteItem(id.toString());
};
return (
<Button
type="submit"
className="bg-red-500 hover:bg-red-800 text-white"
onClick={handleDelete}
>
Delete Item
</Button>
<div>
{/* delete confirmation dialog */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
className="bg-red-500 hover:bg-red-800 text-white"
>
Delete Item
</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";
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,
@ -18,11 +14,6 @@ import {
} 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,
@ -32,65 +23,93 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
// import { updateInventoryItem } from "@/api/inventory";
// import type { UpdateInventoryItemInput } from "@/types";
export interface EditInventoryItemProps {
id: string;
name: string;
category: string;
status: string;
type: string;
unit: string;
quantity: number;
}
import {
InventoryStatus,
InventoryItemCategory,
HarvestUnits,
UpdateInventoryItemInput,
EditInventoryItemInput,
} from "@/types";
import { updateInventoryItem } from "@/api/inventory";
export function EditInventoryItem({
id,
name,
category,
status,
type,
unit,
quantity,
}: EditInventoryItemProps) {
item,
fetchedInventoryStatus,
fetchedInventoryCategory,
fetchedHarvestUnits,
}: {
item: UpdateInventoryItemInput;
fetchedInventoryStatus: InventoryStatus[];
fetchedInventoryCategory: InventoryItemCategory[];
fetchedHarvestUnits: HarvestUnits[];
}) {
// console.table(item);
// console.log(item.id);
const [open, setOpen] = useState(false);
const [itemName, setItemName] = useState(name);
const [itemType, setItemType] = useState(type);
const [itemCategory, setItemCategory] = useState(category);
const [itemQuantity, setItemQuantity] = useState(quantity);
const [itemUnit, setItemUnit] = useState(unit);
const [itemStatus, setItemStatus] = useState(status);
const [itemName, setItemName] = useState(item.name);
const [itemCategory, setItemCategory] = useState(
fetchedInventoryCategory.find((x) => x.id === item.categoryId)?.name
);
// const queryClient = useQueryClient();
const [itemQuantity, setItemQuantity] = useState(item.quantity);
// 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 [itemUnit, setItemUnit] = useState(
fetchedHarvestUnits.find((x) => x.id === item.unitId)?.name
);
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 = () => {
// // 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,
// });
if (!itemName || !itemCategory || !itemUnit) {
setError("All fields are required. Please fill in missing details.");
return;
}
const category = fetchedInventoryCategory.find(
(c) => c.name === itemCategory
)?.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 (
@ -119,17 +138,20 @@ export function EditInventoryItem({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">
Type
Category
</Label>
<Select value={itemType.toLowerCase()} onValueChange={setItemType}>
<Select value={itemCategory} onValueChange={setItemCategory}>
<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>
<SelectLabel>Category</SelectLabel>
{fetchedInventoryCategory.map((categoryItem, _) => (
<SelectItem key={categoryItem.id} value={categoryItem.name}>
{categoryItem.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
@ -138,35 +160,22 @@ export function EditInventoryItem({
<Label htmlFor="type" className="text-right">
Status
</Label>
<Select
value={itemStatus.toLowerCase()}
onValueChange={setItemStatus}
>
<Select value={itemStatus} onValueChange={setItemStatus}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Status</SelectLabel>
<SelectItem value="in stock">In Stock</SelectItem>
<SelectItem value="low stock">Low Stock</SelectItem>
<SelectItem value="out of stock">Out Of Stock</SelectItem>
{fetchedInventoryStatus.map((statusItem, _) => (
<SelectItem key={statusItem.id} value={statusItem.name}>
{statusItem.name}
</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
@ -180,21 +189,30 @@ export function EditInventoryItem({
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="unit" className="text-right">
<Label htmlFor="type" 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)}
/>
<Select value={itemUnit} onValueChange={setItemUnit}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Unit</SelectLabel>
{fetchedHarvestUnits.map((unit, _) => (
<SelectItem key={unit.id} value={unit.name}>
{unit.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
{error && <p className="text-red-500 text-sm">{error}</p>}
<Button type="submit" onClick={handleEdit}>
Edit Item
Save
</Button>
</DialogFooter>
</DialogContent>

View File

@ -22,22 +22,27 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TriangleAlertIcon } from "lucide-react";
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Search } from "lucide-react";
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
import { fetchHarvestUnits } from "@/api/harvest";
import { Badge } from "@/components/ui/badge";
import { fetchInventoryItems, fetchInventoryStatus } from "@/api/inventory";
import { AddInventoryItem } from "./add-inventory-item";
import {
EditInventoryItem,
EditInventoryItemProps,
} from "./edit-inventory-item";
fetchInventoryItems,
fetchInventoryStatus,
fetchInventoryCategory,
} from "@/api/inventory";
import { AddInventoryItem } from "./add-inventory-item";
import { EditInventoryItem } from "./edit-inventory-item";
import { DeleteInventoryItem } from "./delete-inventory-item";
import { InventoryItem } from "@/types";
export default function InventoryPage() {
const [sorting, setSorting] = useState<SortingState>([]);
@ -45,7 +50,8 @@ export default function InventoryPage() {
pageIndex: 0,
pageSize: 10,
});
//////////////////////////////
// query the necessary data for edit and etc.
const {
data: inventoryItems = [],
isLoading: isItemLoading,
@ -55,6 +61,8 @@ export default function InventoryPage() {
queryFn: fetchInventoryItems,
staleTime: 60 * 1000,
});
// console.table(inventoryItems);
// console.log(inventoryItems);
const {
data: inventoryStatus = [],
@ -65,38 +73,98 @@ export default function InventoryPage() {
queryFn: fetchInventoryStatus,
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(inventoryStatus);
// console.table(inventoryStatus);
// console.table(harvestUnits);
const [searchTerm, setSearchTerm] = useState("");
const filteredItems = useMemo(() => {
return inventoryItems
.map((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) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [inventoryItems, searchTerm]);
// prepare columns for table
const columns = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "category", header: "Category" },
{ accessorKey: "quantity", header: "Quantity" },
{ accessorKey: "unit", header: "Unit" },
{ accessorKey: "lastUpdated", header: "Last Updated" },
{
accessorKey: "category",
header: "Category",
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",
header: "Status",
cell: (info: { getValue: () => string }) => {
const status = info.getValue();
cell: ({ row }: { row: { original: InventoryItem } }) => {
const status = row.original.status.name;
let statusClass = ""; // default status class
let statusClass = "";
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
if (status === "In Stock") {
statusClass = "bg-green-500 hover:bg-green-600 text-white";
} else if (status === "Low 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 (
@ -109,15 +177,30 @@ export default function InventoryPage() {
{
accessorKey: "edit",
header: "Edit",
cell: ({ row }: { row: { original: EditInventoryItemProps } }) => (
<EditInventoryItem {...row.original} />
cell: ({ row }: { row: { original: InventoryItem } }) => (
<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,
},
{
accessorKey: "delete",
header: "Delete",
cell: () => <DeleteInventoryItem />,
cell: ({ row }: { row: { original: InventoryItem } }) => (
<DeleteInventoryItem id={row.original.id} />
),
enableSorting: false,
},
];
@ -132,20 +215,61 @@ export default function InventoryPage() {
onSortingChange: setSorting,
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 (
<div className="flex min-h-screen items-center justify-center">
Loading...
</div>
);
if (isItemError || isErrorStatus)
if (isError)
return (
<div className="flex min-h-screen items-center justify-center">
Error loading inventory data.
</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 (
<div className="flex min-h-screen bg-background">
<div className="flex-1 flex flex-col">
@ -159,7 +283,11 @@ export default function InventoryPage() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<AddInventoryItem />
<AddInventoryItem
inventoryCategory={inventoryCategory}
inventoryStatus={inventoryStatus}
harvestUnits={harvestUnits}
/>
</div>
<div className="border rounded-md">
<Table>

View File

@ -14,9 +14,18 @@ import type { z } from "zod";
import { useRouter } from "next/navigation";
import { registerUser } from "@/api/authentication";
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 { Progress } from "@/components/ui/progress";
import { GoogleSigninButton } from "../signin/google-oauth";
export default function SignupPage() {
const [serverError, setServerError] = useState<string | null>(null);
@ -75,7 +84,9 @@ export default function SignupPage() {
const data = await registerUser(values.email, values.password);
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.");
}
@ -120,9 +131,12 @@ export default function SignupPage() {
</Link>
</div>
<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">
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>
<div className="space-y-4">
{[
@ -148,11 +162,18 @@ export default function SignupPage() {
<div className="flex justify-center items-center p-6">
<div className="w-full max-w-md">
{/* 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">
<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>
</div>
@ -160,7 +181,10 @@ export default function SignupPage() {
<h1 className="text-3xl font-bold mb-2">Create your account</h1>
<p className="text-gray-500 dark:text-gray-400">
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
</Link>
</p>
@ -184,7 +208,10 @@ export default function SignupPage() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* Email */}
<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
</Label>
<div className="relative">
@ -192,7 +219,11 @@ export default function SignupPage() {
type="email"
id="email"
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")}
/>
</div>
@ -206,7 +237,10 @@ export default function SignupPage() {
{/* Password */}
<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
</Label>
<div className="relative">
@ -214,15 +248,26 @@ export default function SignupPage() {
type={showPassword ? "text" : "password"}
id="password"
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 })}
/>
<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"
onClick={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
@ -230,7 +275,9 @@ export default function SignupPage() {
{password && (
<div className="mt-2 space-y-1">
<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
className={`text-xs font-medium ${
passwordStrength <= 25
@ -240,11 +287,15 @@ export default function SignupPage() {
: passwordStrength <= 75
? "text-blue-500"
: "text-green-500"
}`}>
}`}
>
{getPasswordStrengthText()}
</span>
</div>
<Progress value={passwordStrength} className={`${getPasswordStrengthColor()} h-1`} />
<Progress
value={passwordStrength}
className={`${getPasswordStrengthColor()} h-1`}
/>
</div>
)}
@ -258,7 +309,10 @@ export default function SignupPage() {
{/* Confirm Password */}
<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
</Label>
<div className="relative">
@ -266,15 +320,26 @@ export default function SignupPage() {
type={showConfirmPassword ? "text" : "password"}
id="confirmPassword"
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")}
/>
<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"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}>
{showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
{errors.confirmPassword && (
@ -288,7 +353,8 @@ export default function SignupPage() {
<Button
type="submit"
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 ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
@ -316,22 +382,23 @@ export default function SignupPage() {
</div>
<div className="mt-6">
<Button
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>
<GoogleSigninButton />
</div>
</div>
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-8">
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
</Link>{" "}
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
</Link>
</p>

File diff suppressed because it is too large Load Diff

View File

@ -142,15 +142,30 @@ export interface HarvestUnit {
name: string;
}
export interface CreateInventoryItemInput {
export type InventoryItemCategory = {
id: number;
name: string;
};
export type HarvestUnits = {
id: number;
name: string;
};
export type CreateInventoryItemInput = {
name: string;
categoryId: number;
quantity: number;
unitId: number;
dateAdded: string;
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 {
id: number;
@ -227,22 +242,32 @@ export interface 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;
}
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;
}
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;
}
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;
}
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;
}