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
This commit is contained in:
Tiago Yamamoto 2025-12-26 22:16:48 -03:00
parent 41862b3d5c
commit 240ce9a7e5
9 changed files with 200 additions and 108 deletions

View file

@ -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.

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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))

View file

@ -29,7 +29,7 @@ export const GroupedProductCard = ({ group, onClick }: GroupedProductCardProps)
Vence em breve
</span>
)}
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium">
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap">
{group.offerCount} {group.offerCount === 1 ? 'oferta' : 'ofertas'}
</span>
</div>

View file

@ -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<Record<string, number>>({})
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)
</button>
</div>
<div className="mt-4 flex items-center gap-4 text-sm">
<span className="bg-white/20 px-3 py-1 rounded-full">
<span className="bg-white/20 px-3 py-1 rounded-full whitespace-nowrap">
{group.offerCount} {group.offerCount === 1 ? 'oferta disponível' : 'ofertas disponíveis'}
</span>
<span className="bg-green-500 px-3 py-1 rounded-full">
<span className="bg-green-500 px-3 py-1 rounded-full whitespace-nowrap">
A partir de {formatCents(group.minPriceCents)}
</span>
</div>
@ -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 (
<div
@ -74,40 +85,40 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
>
<div className="flex justify-between items-start gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center gap-2 mb-2 flex-wrap">
{isCheapest && (
<span className="bg-green-600 text-white text-xs px-2 py-0.5 rounded font-medium">
<span className="bg-green-600 text-white text-xs px-2 py-0.5 rounded font-medium whitespace-nowrap">
Menor preço
</span>
)}
{isExpiringSoon && (
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded font-medium">
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded font-medium whitespace-nowrap">
Vence em breve
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<div className="whitespace-nowrap">
<span className="text-gray-500">Lote:</span>
<span className="ml-2 font-mono text-gray-700">{offer.batch}</span>
<span className="ml-1 font-mono text-gray-700">{offer.batch}</span>
</div>
<div>
<div className="whitespace-nowrap">
<span className="text-gray-500">Validade:</span>
<span className={`ml-2 font-medium ${isExpiringSoon ? 'text-amber-600' : 'text-gray-700'}`}>
<span className={`ml-1 font-medium ${isExpiringSoon ? 'text-amber-600' : 'text-gray-700'}`}>
{new Date(offer.expires_at).toLocaleDateString()}
</span>
</div>
<div className="col-span-2 flex items-center text-blue-600">
<div className="flex items-center text-blue-600 whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{offer.distance_km} km {offer.tenant_city}/{offer.tenant_state}</span>
</div>
<div>
<div className="whitespace-nowrap">
<span className="text-gray-500">Estoque:</span>
<span className="ml-2 font-medium text-gray-700">{offer.stock} un.</span>
<span className="ml-1 font-medium text-gray-700">{offer.stock} un.</span>
</div>
</div>
</div>
@ -116,15 +127,41 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
<div className="text-2xl font-bold text-green-600">
{formatCents(offer.price_cents)}
</div>
<button
onClick={() => handleAddToCart(offer)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
Adicionar
</button>
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
onClick={() => setQuantity(offer.id, qty - 1)}
disabled={qty <= 1}
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed text-gray-600 font-bold"
>
</button>
<input
type="number"
min="1"
max={maxQty}
value={qty}
onChange={(e) => setQuantity(offer.id, Math.min(maxQty, parseInt(e.target.value) || 1))}
className="w-12 text-center border-x py-1 text-sm font-medium"
/>
<button
onClick={() => setQuantity(offer.id, Math.min(maxQty, qty + 1))}
disabled={qty >= maxQty}
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed text-gray-600 font-bold"
>
+
</button>
</div>
<button
onClick={() => handleAddToCart(offer)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
Adicionar
</button>
</div>
</div>
</div>
</div>
@ -146,3 +183,4 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
</div>
)
}

View file

@ -143,20 +143,7 @@ const ProductSearch = () => {
<h1 className="text-3xl font-bold mb-6 text-gray-800">Encontre Medicamentos Próximos</h1>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Filters & Map */}
<div className="lg:col-span-1 space-y-6">
<div className="bg-white p-4 rounded-lg shadow border border-gray-200">
<h2 className="text-lg font-semibold mb-3">Sua Localização</h2>
<LocationPicker
initialLat={lat}
initialLng={lng}
onLocationSelect={handleLocationSelect}
/>
<p className="text-xs text-gray-500 mt-2">
Clique no mapa para ajustar sua localização.
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow border border-gray-200 space-y-4">
<h2 className="text-lg font-semibold">Filtros</h2>
@ -260,6 +247,18 @@ const ProductSearch = () => {
</div>
)}
</div>
<div className="bg-white p-4 rounded-lg shadow border border-gray-200">
<h2 className="text-lg font-semibold mb-3">Sua Localização</h2>
<LocationPicker
initialLat={lat}
initialLng={lng}
onLocationSelect={handleLocationSelect}
/>
<p className="text-xs text-gray-500 mt-2">
Clique no mapa para ajustar sua localização.
</p>
</div>
</div>
{/* Results */}