From 240ce9a7e5ec5ddccd6cc8ac0931c05e2318854f Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Fri, 26 Dec 2025 22:16:48 -0300 Subject: [PATCH] feat: add quantity selector, fix offer display, swap filter/location layout - ProductOffersModal: Add quantity input for each offer when purchasing - ProductOffersModal: Display offer info in single line with flex-wrap - GroupedProductCard: Add whitespace-nowrap to prevent 'oferta' badge wrapping - ProductSearch: Swap Filters and Location components (Filters now first) - Backend: Refactored admin routes to use role-based access control - review_handler: New handler with role-based filtering - shipping_handler: Added ListShipments with role-based filtering - domain/models: Added SellerID to ReviewFilter and ShipmentFilter - postgres.go: Updated ListReviews and ListShipments for SellerID filtering - server.go: Removed /api/v1/admin routes, updated handlers --- backend/internal/domain/models.go | 10 ++- .../internal/http/handler/admin_handler.go | 55 ------------ .../internal/http/handler/review_handler.go | 51 +++++++++++ .../internal/http/handler/shipping_handler.go | 41 +++++++++ .../internal/repository/postgres/postgres.go | 33 ++++++-- backend/internal/server/server.go | 7 +- .../src/components/GroupedProductCard.tsx | 2 +- .../src/components/ProductOffersModal.tsx | 84 ++++++++++++++----- marketplace/src/pages/ProductSearch.tsx | 25 +++--- 9 files changed, 200 insertions(+), 108 deletions(-) delete mode 100644 backend/internal/http/handler/admin_handler.go create mode 100644 backend/internal/http/handler/review_handler.go diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 30aad0d..9343dd1 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -281,8 +281,9 @@ type Shipment struct { // ReviewFilter captures review listing constraints. type ReviewFilter struct { - Limit int - Offset int + SellerID *uuid.UUID + Limit int + Offset int } // ReviewPage wraps paginated review results. @@ -295,8 +296,9 @@ type ReviewPage struct { // ShipmentFilter captures shipment listing constraints. type ShipmentFilter struct { - Limit int - Offset int + SellerID *uuid.UUID + Limit int + Offset int } // ShipmentPage wraps paginated shipment results. diff --git a/backend/internal/http/handler/admin_handler.go b/backend/internal/http/handler/admin_handler.go deleted file mode 100644 index a544575..0000000 --- a/backend/internal/http/handler/admin_handler.go +++ /dev/null @@ -1,55 +0,0 @@ -package handler - -import ( - "net/http" - - "github.com/saveinmed/backend-go/internal/domain" -) - -// ListAllReviews godoc -// @Summary Lista todas as avaliações (Admin) -// @Tags Admin -// @Security BearerAuth -// @Produce json -// @Param page query int false "Página" -// @Param page_size query int false "Tamanho da página" -// @Success 200 {object} domain.ReviewPage -// @Failure 401 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/admin/reviews [get] -// @Router /api/v1/reviews [get] -func (h *Handler) ListAllReviews(w http.ResponseWriter, r *http.Request) { - page, pageSize := parsePagination(r) - - result, err := h.svc.ListReviews(r.Context(), domain.ReviewFilter{}, page, pageSize) - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - - writeJSON(w, http.StatusOK, result) -} - -// ListAllShipments godoc -// @Summary Lista todos os envios (Admin) -// @Tags Admin -// @Security BearerAuth -// @Produce json -// @Param page query int false "Página" -// @Param page_size query int false "Tamanho da página" -// @Success 200 {object} domain.ShipmentPage -// @Failure 401 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /api/v1/admin/shipments [get] -// @Router /api/v1/shipments [get] -func (h *Handler) ListAllShipments(w http.ResponseWriter, r *http.Request) { - page, pageSize := parsePagination(r) - - result, err := h.svc.ListShipments(r.Context(), domain.ShipmentFilter{}, page, pageSize) - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - - writeJSON(w, http.StatusOK, result) -} diff --git a/backend/internal/http/handler/review_handler.go b/backend/internal/http/handler/review_handler.go new file mode 100644 index 0000000..7f2d55e --- /dev/null +++ b/backend/internal/http/handler/review_handler.go @@ -0,0 +1,51 @@ +package handler + +import ( + "errors" + "net/http" + "strings" + + "github.com/saveinmed/backend-go/internal/domain" +) + +// ListReviews godoc +// @Summary List reviews +// @Description Returns reviews. Admins see all, Tenants see only their own. +// @Tags Reviews +// @Security BearerAuth +// @Produce json +// @Param page query int false "Página" +// @Param page_size query int false "Tamanho da página" +// @Success 200 {object} domain.ReviewPage +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/reviews [get] +func (h *Handler) ListReviews(w http.ResponseWriter, r *http.Request) { + page, pageSize := parsePagination(r) + + requester, err := getRequester(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + + filter := domain.ReviewFilter{} + if !strings.EqualFold(requester.Role, "Admin") { + if requester.CompanyID == nil { + writeError(w, http.StatusForbidden, errors.New("user has no company associated")) + return + } + // Assuming SellerID logic: + // Reviews are usually linked to a Seller (Vendor/Pharmacy). + // If the user is a Tenant/Seller, they should only see reviews where they are the seller. + filter.SellerID = requester.CompanyID + } + + result, err := h.svc.ListReviews(r.Context(), filter, page, pageSize) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, result) +} diff --git a/backend/internal/http/handler/shipping_handler.go b/backend/internal/http/handler/shipping_handler.go index 134832d..1dd3150 100644 --- a/backend/internal/http/handler/shipping_handler.go +++ b/backend/internal/http/handler/shipping_handler.go @@ -180,3 +180,44 @@ func parseShippingMethodType(value string) (domain.ShippingMethodType, error) { return "", errors.New("invalid shipping method type") } } + +// ListShipments godoc +// @Summary List shipments +// @Description Returns shipments. Admins see all, Tenants see only their own. +// @Tags Shipments +// @Security BearerAuth +// @Produce json +// @Param page query int false "Página" +// @Param page_size query int false "Tamanho da página" +// @Success 200 {object} domain.ShipmentPage +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/shipments [get] +func (h *Handler) ListShipments(w http.ResponseWriter, r *http.Request) { + page, pageSize := parsePagination(r) + + requester, err := getRequester(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + + filter := domain.ShipmentFilter{} + if !strings.EqualFold(requester.Role, "Admin") { + if requester.CompanyID == nil { + writeError(w, http.StatusForbidden, errors.New("user has no company associated")) + return + } + // Shipments logic: + // Shipments are linked to orders, and orders belong to sellers. + filter.SellerID = requester.CompanyID + } + + result, err := h.svc.ListShipments(r.Context(), filter, page, pageSize) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, result) +} diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 1a3d0da..89753fc 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -1119,11 +1119,20 @@ SET active = EXCLUDED.active, func (r *Repository) ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error) { baseQuery := `FROM reviews` var args []any + var clauses []string - // Add where clauses if needed in future + if filter.SellerID != nil { + clauses = append(clauses, fmt.Sprintf("seller_id = $%d", len(args)+1)) + args = append(args, *filter.SellerID) + } + + where := "" + if len(clauses) > 0 { + where = " WHERE " + strings.Join(clauses, " AND ") + } var total int64 - if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery, args...); err != nil { + if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil { return nil, 0, err } @@ -1132,7 +1141,7 @@ func (r *Repository) ListReviews(ctx context.Context, filter domain.ReviewFilter } args = append(args, filter.Limit, filter.Offset) - listQuery := fmt.Sprintf("SELECT id, order_id, buyer_id, seller_id, rating, comment, created_at %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, len(args)-1, len(args)) + listQuery := fmt.Sprintf("SELECT id, order_id, buyer_id, seller_id, rating, comment, created_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args)) var reviews []domain.Review if err := r.db.SelectContext(ctx, &reviews, listQuery, args...); err != nil { @@ -1142,13 +1151,23 @@ func (r *Repository) ListReviews(ctx context.Context, filter domain.ReviewFilter } func (r *Repository) ListShipments(ctx context.Context, filter domain.ShipmentFilter) ([]domain.Shipment, int64, error) { - baseQuery := `FROM shipments` + baseQuery := `FROM shipments s` var args []any + var clauses []string - // Add where clauses if needed in future + if filter.SellerID != nil { + baseQuery += ` JOIN orders o ON s.order_id = o.id` + clauses = append(clauses, fmt.Sprintf("o.seller_id = $%d", len(args)+1)) + args = append(args, *filter.SellerID) + } + + where := "" + if len(clauses) > 0 { + where = " WHERE " + strings.Join(clauses, " AND ") + } var total int64 - if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery, args...); err != nil { + if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil { return nil, 0, err } @@ -1157,7 +1176,7 @@ func (r *Repository) ListShipments(ctx context.Context, filter domain.ShipmentFi } args = append(args, filter.Limit, filter.Offset) - listQuery := fmt.Sprintf("SELECT id, order_id, carrier, tracking_code, external_tracking, status, created_at, updated_at %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, len(args)-1, len(args)) + listQuery := fmt.Sprintf("SELECT s.id, s.order_id, s.carrier, s.tracking_code, s.external_tracking, s.status, s.created_at, s.updated_at %s%s ORDER BY s.created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args)) var shipments []domain.Shipment if err := r.db.SelectContext(ctx, &shipments, listQuery, args...); err != nil { diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index b4cd561..67a2e8f 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -90,19 +90,16 @@ func New(cfg config.Config) (*Server, error) { mux.Handle("POST /api/v1/orders/{id}/payment", chain(http.HandlerFunc(h.CreatePaymentPreference), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/v1/shipments", chain(http.HandlerFunc(h.CreateShipment), middleware.Logger, middleware.Gzip, auth)) - mux.Handle("GET /api/v1/shipments", chain(http.HandlerFunc(h.ListAllShipments), middleware.Logger, middleware.Gzip, adminOnly)) + mux.Handle("GET /api/v1/shipments", chain(http.HandlerFunc(h.ListShipments), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/v1/shipments/", chain(http.HandlerFunc(h.GetShipmentByOrderID), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/v1/payments/webhook", chain(http.HandlerFunc(h.HandlePaymentWebhook), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/reviews", chain(http.HandlerFunc(h.CreateReview), middleware.Logger, middleware.Gzip, auth)) - mux.Handle("GET /api/v1/reviews", chain(http.HandlerFunc(h.ListAllReviews), middleware.Logger, middleware.Gzip, adminOnly)) + mux.Handle("GET /api/v1/reviews", chain(http.HandlerFunc(h.ListReviews), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/v1/dashboard/seller", chain(http.HandlerFunc(h.GetSellerDashboard), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/v1/dashboard/admin", chain(http.HandlerFunc(h.GetAdminDashboard), middleware.Logger, middleware.Gzip, adminOnly)) - mux.Handle("GET /api/v1/admin/reviews", chain(http.HandlerFunc(h.ListAllReviews), middleware.Logger, middleware.Gzip, adminOnly)) - mux.Handle("GET /api/v1/admin/shipments", chain(http.HandlerFunc(h.ListAllShipments), middleware.Logger, middleware.Gzip, adminOnly)) - mux.Handle("POST /api/v1/auth/register", chain(http.HandlerFunc(h.Register), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/auth/register/customer", chain(http.HandlerFunc(h.RegisterCustomer), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/auth/register/tenant", chain(http.HandlerFunc(h.RegisterTenant), middleware.Logger, middleware.Gzip)) diff --git a/marketplace/src/components/GroupedProductCard.tsx b/marketplace/src/components/GroupedProductCard.tsx index e057ae8..af0df93 100644 --- a/marketplace/src/components/GroupedProductCard.tsx +++ b/marketplace/src/components/GroupedProductCard.tsx @@ -29,7 +29,7 @@ export const GroupedProductCard = ({ group, onClick }: GroupedProductCardProps) Vence em breve )} - + {group.offerCount} {group.offerCount === 1 ? 'oferta' : 'ofertas'} diff --git a/marketplace/src/components/ProductOffersModal.tsx b/marketplace/src/components/ProductOffersModal.tsx index 772397f..fd9ed60 100644 --- a/marketplace/src/components/ProductOffersModal.tsx +++ b/marketplace/src/components/ProductOffersModal.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { GroupedProduct, ProductWithDistance } from '../types/product' import { formatCents } from '../utils/format' import { useCartStore } from '../stores/cartStore' @@ -9,8 +10,16 @@ interface ProductOffersModalProps { export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps) => { const addItem = useCartStore((state) => state.addItem) + const [quantities, setQuantities] = useState>({}) + + const getQuantity = (productId: string) => quantities[productId] || 1 + + const setQuantity = (productId: string, qty: number) => { + setQuantities(prev => ({ ...prev, [productId]: Math.max(1, qty) })) + } const handleAddToCart = (product: ProductWithDistance) => { + const qty = getQuantity(product.id) addItem({ id: product.id, name: product.name, @@ -21,7 +30,7 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps) vendorId: product.seller_id, vendorName: `${product.tenant_city}/${product.tenant_state}`, unitPrice: product.price_cents - }) + }, qty) } // Sort by price (cheapest first) @@ -50,10 +59,10 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
- + {group.offerCount} {group.offerCount === 1 ? 'oferta disponível' : 'ofertas disponíveis'} - + A partir de {formatCents(group.minPriceCents)}
@@ -65,6 +74,8 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps) {sortedOffers.map((offer, index) => { const isExpiringSoon = new Date(offer.expires_at).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000 const isCheapest = index === 0 + const qty = getQuantity(offer.id) + const maxQty = offer.stock || 999 return (
-
+
{isCheapest && ( - + Menor preço )} {isExpiringSoon && ( - + Vence em breve )}
-
-
+
+
Lote: - {offer.batch} + {offer.batch}
-
+
Validade: - + {new Date(offer.expires_at).toLocaleDateString()}
-
+
{offer.distance_km} km • {offer.tenant_city}/{offer.tenant_state}
-
+
Estoque: - {offer.stock} un. + {offer.stock} un.
@@ -116,15 +127,41 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
{formatCents(offer.price_cents)}
- +
+
+ + setQuantity(offer.id, Math.min(maxQty, parseInt(e.target.value) || 1))} + className="w-12 text-center border-x py-1 text-sm font-medium" + /> + +
+ +
@@ -146,3 +183,4 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
) } + diff --git a/marketplace/src/pages/ProductSearch.tsx b/marketplace/src/pages/ProductSearch.tsx index 54a03e5..73d9ea1 100644 --- a/marketplace/src/pages/ProductSearch.tsx +++ b/marketplace/src/pages/ProductSearch.tsx @@ -143,20 +143,7 @@ const ProductSearch = () => {

Encontre Medicamentos Próximos

- {/* Filters & Map */}
-
-

Sua Localização

- -

- Clique no mapa para ajustar sua localização. -

-
-

Filtros

@@ -260,6 +247,18 @@ const ProductSearch = () => {
)}
+ +
+

Sua Localização

+ +

+ Clique no mapa para ajustar sua localização. +

+
{/* Results */}