mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-18 21:44:08 +01:00
Merge branch 'main' into feature-farm-setup
This commit is contained in:
commit
9d320ccb2f
@ -30,13 +30,14 @@ type api struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
eventPublisher domain.EventPublisher
|
eventPublisher domain.EventPublisher
|
||||||
|
|
||||||
userRepo domain.UserRepository
|
userRepo domain.UserRepository
|
||||||
cropRepo domain.CroplandRepository
|
cropRepo domain.CroplandRepository
|
||||||
farmRepo domain.FarmRepository
|
farmRepo domain.FarmRepository
|
||||||
plantRepo domain.PlantRepository
|
plantRepo domain.PlantRepository
|
||||||
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)
|
||||||
@ -87,15 +89,15 @@ func NewAPI(
|
|||||||
httpClient: client,
|
httpClient: client,
|
||||||
eventPublisher: eventPublisher,
|
eventPublisher: eventPublisher,
|
||||||
|
|
||||||
userRepo: userRepository,
|
userRepo: userRepository,
|
||||||
cropRepo: croplandRepo,
|
cropRepo: croplandRepo,
|
||||||
farmRepo: farmRepo,
|
farmRepo: farmRepo,
|
||||||
plantRepo: plantRepository,
|
plantRepo: plantRepository,
|
||||||
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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
372
backend/internal/api/knowledgeHub.go
Normal file
372
backend/internal/api/knowledgeHub.go
Normal 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
|
||||||
|
}
|
||||||
78
backend/internal/domain/knowledgeHub.go
Normal file
78
backend/internal/domain/knowledgeHub.go
Normal 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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
265
backend/internal/repository/postgres_knowledgeHub.go
Normal file
265
backend/internal/repository/postgres_knowledgeHub.go
Normal 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
|
||||||
|
}
|
||||||
37
backend/internal/service/toc_generator.go
Normal file
37
backend/internal/service/toc_generator.go
Normal 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
|
||||||
|
}
|
||||||
41
backend/migrations/00004_create_knowledge_hub_tables.sql
Normal file
41
backend/migrations/00004_create_knowledge_hub_tables.sql
Normal 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
|
||||||
7
backend/migrations/00005_add_image_url_to_articles.sql
Normal file
7
backend/migrations/00005_add_image_url_to_articles.sql
Normal 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
12
frontend/api/harvest.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,20 +1,23 @@
|
|||||||
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.
|
||||||
* Waits for a simulated delay and then attempts an axios GET request.
|
* Waits for a simulated delay and then attempts an axios GET request.
|
||||||
* If the request fails, returns fallback dummy data.
|
* If the request fails, returns fallback dummy data.
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,142 +33,242 @@ 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);
|
||||||
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 = () => {
|
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}>
|
<>
|
||||||
<DialogTrigger asChild>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<Button>Add New Item</Button>
|
<DialogTrigger asChild>
|
||||||
</DialogTrigger>
|
<Button>Add New Item</Button>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
</DialogTrigger>
|
||||||
<DialogHeader>
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogTitle>Add Inventory Item</DialogTitle>
|
<DialogHeader>
|
||||||
<DialogDescription>Add a new plantation or fertilizer item to your inventory.</DialogDescription>
|
<DialogTitle>Add Inventory Item</DialogTitle>
|
||||||
</DialogHeader>
|
<DialogDescription>
|
||||||
<div className="grid gap-4 py-4">
|
Add a new plantation or fertilizer item to your inventory.
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
</DialogDescription>
|
||||||
<Label htmlFor="name" className="text-right">
|
</DialogHeader>
|
||||||
Name
|
<div className="grid gap-4 py-4">
|
||||||
</Label>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Input id="name" className="col-span-3" value={itemName} onChange={(e) => setItemName(e.target.value)} />
|
<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>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<DialogFooter>
|
||||||
<Label htmlFor="type" className="text-right">
|
<div className="flex flex-col items-center w-full space-y-2">
|
||||||
Type
|
<Button type="button" onClick={handleSave} className="w-full">
|
||||||
</Label>
|
Save Item
|
||||||
<Select value={itemType} onValueChange={setItemType}>
|
</Button>
|
||||||
<SelectTrigger className="col-span-3">
|
|
||||||
<SelectValue placeholder="Select type" />
|
<div className="flex flex-col items-center space-y-2">
|
||||||
</SelectTrigger>
|
{isSubmitted && (
|
||||||
<SelectContent>
|
<p className="text-green-500 text-sm">
|
||||||
<SelectGroup>
|
{successMessage} You may close this window.
|
||||||
<SelectLabel>Type</SelectLabel>
|
</p>
|
||||||
<SelectItem value="plantation">Plantation</SelectItem>
|
)}
|
||||||
<SelectItem value="fertilizer">Fertilizer</SelectItem>
|
|
||||||
</SelectGroup>
|
{errorMessage && (
|
||||||
</SelectContent>
|
<p className="text-red-500 text-sm">{errorMessage}</p>
|
||||||
</Select>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
</div>
|
||||||
<Label htmlFor="category" className="text-right">
|
</DialogFooter>
|
||||||
Category
|
</DialogContent>
|
||||||
</Label>
|
</Dialog>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
<Button
|
<div>
|
||||||
type="submit"
|
{/* delete confirmation dialog */}
|
||||||
className="bg-red-500 hover:bg-red-800 text-white"
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
onClick={handleDelete}
|
<DialogTrigger asChild>
|
||||||
>
|
<Button
|
||||||
Delete Item
|
type="button"
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user