From 4ccfa629cc099bd0d8f82d2a22979fa60be564c7 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Mon, 22 Dec 2025 09:08:42 -0300 Subject: [PATCH] feat: add admin reviews, logistics, profile pages and update seeder --- backend/internal/domain/models.go | 28 ++++ .../internal/http/handler/admin_handler.go | 53 ++++++ .../internal/repository/postgres/postgres.go | 50 ++++++ backend/internal/server/server.go | 3 + backend/internal/usecase/usecase.go | 35 ++++ marketplace/src/App.tsx | 9 +- marketplace/src/components/Header.tsx | 10 +- marketplace/src/context/AuthContext.tsx | 13 +- marketplace/src/pages/Login.tsx | 4 +- marketplace/src/pages/admin/LogisticsPage.tsx | 140 ++++++++++++++++ marketplace/src/pages/admin/ProfilePage.tsx | 156 ++++++++++++++++++ marketplace/src/pages/admin/ReviewsPage.tsx | 135 +++++++++++++++ marketplace/src/pages/admin/index.ts | 3 + marketplace/src/services/adminService.ts | 52 ++++++ seeder-api/pkg/seeder/seeder.go | 20 +++ 15 files changed, 700 insertions(+), 11 deletions(-) create mode 100644 backend/internal/http/handler/admin_handler.go create mode 100644 marketplace/src/pages/admin/LogisticsPage.tsx create mode 100644 marketplace/src/pages/admin/ProfilePage.tsx create mode 100644 marketplace/src/pages/admin/ReviewsPage.tsx diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index b4b515e..93b6c4b 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -251,6 +251,34 @@ type Shipment struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +// ReviewFilter captures review listing constraints. +type ReviewFilter struct { + Limit int + Offset int +} + +// ReviewPage wraps paginated review results. +type ReviewPage struct { + Reviews []Review `json:"reviews"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ShipmentFilter captures shipment listing constraints. +type ShipmentFilter struct { + Limit int + Offset int +} + +// ShipmentPage wraps paginated shipment results. +type ShipmentPage struct { + Shipments []Shipment `json:"shipments"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + // OrderStatus enumerates supported transitions. type OrderStatus string diff --git a/backend/internal/http/handler/admin_handler.go b/backend/internal/http/handler/admin_handler.go new file mode 100644 index 0000000..95a49da --- /dev/null +++ b/backend/internal/http/handler/admin_handler.go @@ -0,0 +1,53 @@ +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] +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] +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/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 3200ce2..a3874c0 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -1090,3 +1090,53 @@ SET active = EXCLUDED.active, } return nil } + +func (r *Repository) ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error) { + baseQuery := `FROM reviews` + var args []any + + // Add where clauses if needed in future + + var total int64 + if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery, args...); err != nil { + return nil, 0, err + } + + if filter.Limit <= 0 { + filter.Limit = 20 + } + 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)) + + var reviews []domain.Review + if err := r.db.SelectContext(ctx, &reviews, listQuery, args...); err != nil { + return nil, 0, err + } + return reviews, total, nil +} + +func (r *Repository) ListShipments(ctx context.Context, filter domain.ShipmentFilter) ([]domain.Shipment, int64, error) { + baseQuery := `FROM shipments` + var args []any + + // Add where clauses if needed in future + + var total int64 + if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery, args...); err != nil { + return nil, 0, err + } + + if filter.Limit <= 0 { + filter.Limit = 20 + } + 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)) + + var shipments []domain.Shipment + if err := r.db.SelectContext(ctx, &shipments, listQuery, args...); err != nil { + return nil, 0, err + } + return shipments, total, nil +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 82ad078..d2b31be 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -98,6 +98,9 @@ func New(cfg config.Config) (*Server, error) { 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/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index 1943b43..e211862 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -59,7 +59,10 @@ type Repository interface { SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error) AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error) GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error) + UpsertShippingMethods(ctx context.Context, methods []domain.ShippingMethod) error + ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error) + ListShipments(ctx context.Context, filter domain.ShipmentFilter) ([]domain.Shipment, int64, error) } // PaymentGateway abstracts Mercado Pago integration. @@ -807,3 +810,35 @@ func (s *Service) VerifyCompany(ctx context.Context, id uuid.UUID) (*domain.Comp } return company, nil } + +func (s *Service) ListReviews(ctx context.Context, filter domain.ReviewFilter, page, pageSize int) (*domain.ReviewPage, error) { + if pageSize <= 0 { + pageSize = 20 + } + if page <= 0 { + page = 1 + } + filter.Limit = pageSize + filter.Offset = (page - 1) * pageSize + reviews, total, err := s.repo.ListReviews(ctx, filter) + if err != nil { + return nil, err + } + return &domain.ReviewPage{Reviews: reviews, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (s *Service) ListShipments(ctx context.Context, filter domain.ShipmentFilter, page, pageSize int) (*domain.ShipmentPage, error) { + if pageSize <= 0 { + pageSize = 20 + } + if page <= 0 { + page = 1 + } + filter.Limit = pageSize + filter.Offset = (page - 1) * pageSize + shipments, total, err := s.repo.ListShipments(ctx, filter) + if err != nil { + return nil, err + } + return &domain.ShipmentPage{Shipments: shipments, Total: total, Page: page, PageSize: pageSize}, nil +} diff --git a/marketplace/src/App.tsx b/marketplace/src/App.tsx index be2a8de..e853046 100644 --- a/marketplace/src/App.tsx +++ b/marketplace/src/App.tsx @@ -2,7 +2,6 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { LoginPage } from './pages/Login' import { CartPage } from './pages/Cart' import { CheckoutPage } from './pages/Checkout' -import { ProfilePage } from './pages/Profile' import { OrdersPage as UserOrdersPage } from './pages/Orders' import { InventoryPage } from './pages/Inventory' import { CompanyPage } from './pages/Company' @@ -16,7 +15,10 @@ import { UsersPage, CompaniesPage, ProductsPage, - OrdersPage + OrdersPage, + ReviewsPage, + LogisticsPage, + ProfilePage } from './pages/admin' function App() { @@ -38,6 +40,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> {/* Legacy admin route - redirect to dashboard */} diff --git a/marketplace/src/components/Header.tsx b/marketplace/src/components/Header.tsx index 7ac2bb6..c39ad13 100644 --- a/marketplace/src/components/Header.tsx +++ b/marketplace/src/components/Header.tsx @@ -7,6 +7,8 @@ const navItems = [ { path: '/dashboard/companies', label: 'Empresas' }, { path: '/dashboard/products', label: 'Produtos' }, { path: '/dashboard/orders', label: 'Pedidos' }, + { path: '/dashboard/reviews', label: 'Avaliações' }, + { path: '/dashboard/logistics', label: 'Logística' }, ] export function Header() { @@ -35,8 +37,8 @@ export function Header() { key={item.path} to={item.path} className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${isActive - ? 'bg-white/20 text-white' - : 'text-white/80 hover:bg-white/10 hover:text-white' + ? 'bg-white/20 text-white' + : 'text-white/80 hover:bg-white/10 hover:text-white' }`} > {item.label} @@ -48,7 +50,9 @@ export function Header() { {/* User info */}
-

{user?.name}

+ + {user?.name} +

{user?.role}

+ +
+ + )} + + ) +} diff --git a/marketplace/src/pages/admin/ProfilePage.tsx b/marketplace/src/pages/admin/ProfilePage.tsx new file mode 100644 index 0000000..912205e --- /dev/null +++ b/marketplace/src/pages/admin/ProfilePage.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from 'react' +import { adminService, User } from '../../services/adminService' +import { useAuth } from '../../context/AuthContext' + +export function ProfilePage() { + const { user, setUser: setAuthUser } = useAuth() + const [formData, setFormData] = useState({ + name: '', + username: '', + email: '', + password: '', + confirmPassword: '' + }) + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + if (user) { + setFormData(prev => ({ + ...prev, + name: user.name, + username: user.username || '', + email: user.email || '' + })) + } + }, [user]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!user) return + + if (formData.password && formData.password !== formData.confirmPassword) { + alert('Senhas não conferem') + return + } + + setLoading(true) + setMessage('') + + try { + const updateData: any = { + name: formData.name, + username: formData.username, + email: formData.email + } + if (formData.password) { + updateData.password = formData.password + } + + const updatedUser = await adminService.updateUser(user.id, updateData) + + // Update auth context + // We need to map AdminService User to AuthContext User (they are similar) + setAuthUser({ + ...user, + name: updatedUser.name, + username: updatedUser.username, + email: updatedUser.email + }) + + setMessage('Perfil atualizado com sucesso!') + setFormData(prev => ({ ...prev, password: '', confirmPassword: '' })) + } catch (err) { + console.error('Error updating profile:', err) + setMessage('Erro ao atualizar perfil') + } finally { + setLoading(false) + } + } + + return ( +
+

Meu Perfil

+ +
+ {message && ( +
+ {message} +
+ )} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:outline-none" + required + /> +
+ +
+ + setFormData({ ...formData, username: e.target.value })} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:outline-none" + required + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:outline-none" + required + /> +
+ +
+

Alterar Senha

+

Deixe em branco para manter a senha atual

+ +
+
+ + setFormData({ ...formData, password: e.target.value })} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + setFormData({ ...formData, confirmPassword: e.target.value })} + className="mt-1 block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:outline-none" + /> +
+
+
+ +
+ +
+
+
+
+ ) +} diff --git a/marketplace/src/pages/admin/ReviewsPage.tsx b/marketplace/src/pages/admin/ReviewsPage.tsx new file mode 100644 index 0000000..f7b9e38 --- /dev/null +++ b/marketplace/src/pages/admin/ReviewsPage.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from 'react' +import { adminService, Review } from '../../services/adminService' + +export function ReviewsPage() { + const [reviews, setReviews] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + + const pageSize = 10 + + useEffect(() => { + loadReviews() + }, [page]) + + const loadReviews = async () => { + setLoading(true) + try { + const data = await adminService.listReviews(page, pageSize) + setReviews(data.reviews || []) + setTotal(data.total) + } catch (err) { + console.error('Error loading reviews:', err) + } finally { + setLoading(false) + } + } + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + const renderStars = (rating: number) => { + return ( +
+ {[...Array(5)].map((_, i) => ( + + {i < rating ? '★' : '☆'} + + ))} +
+ ) + } + + const totalPages = Math.ceil(total / pageSize) + + return ( +
+
+

Avaliações

+
+ + {/* Table */} +
+ + + + + + + + + + + + {loading ? ( + + + + ) : reviews.length === 0 ? ( + + + + ) : ( + reviews.map((review) => ( + + + + + + + + )) + )} + +
DataPedidoNotaComentárioComprador ID
+ Carregando... +
+ Nenhuma avaliação encontrada +
+ {formatDate(review.created_at)} + + {review.order_id.substring(0, 8)}... + + {renderStars(review.rating)} + + {review.comment} + + {review.buyer_id.substring(0, 8)}... +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Mostrando {(page - 1) * pageSize + 1} a {Math.min(page * pageSize, total)} de {total} +

+
+ + +
+
+ )} +
+ ) +} diff --git a/marketplace/src/pages/admin/index.ts b/marketplace/src/pages/admin/index.ts index 0192a4a..422dc13 100644 --- a/marketplace/src/pages/admin/index.ts +++ b/marketplace/src/pages/admin/index.ts @@ -3,3 +3,6 @@ export { CompaniesPage } from './CompaniesPage' export { ProductsPage } from './ProductsPage' export { OrdersPage } from './OrdersPage' export { DashboardHome } from './DashboardHome' +export { ReviewsPage } from './ReviewsPage' +export { LogisticsPage } from './LogisticsPage' +export { ProfilePage } from './ProfilePage' diff --git a/marketplace/src/services/adminService.ts b/marketplace/src/services/adminService.ts index 17f4a5e..a626d9f 100644 --- a/marketplace/src/services/adminService.ts +++ b/marketplace/src/services/adminService.ts @@ -302,5 +302,57 @@ export const adminService = { log('deleteOrder done') return result }, + + // ================== REVIEWS ================== + listReviews: async (page = 1, pageSize = 20) => { + log('listReviews', { page, pageSize }) + const result = await apiClient.get(`/v1/admin/reviews?page=${page}&page_size=${pageSize}`) + log('listReviews result', result) + return result + }, + + // ================== SHIPMENTS ================== + listShipments: async (page = 1, pageSize = 20) => { + log('listShipments', { page, pageSize }) + const result = await apiClient.get(`/v1/admin/shipments?page=${page}&page_size=${pageSize}`) + log('listShipments result', result) + return result + }, +} + +// ================== REVIEWS & SHIPMENTS TYPES ================== +export interface Review { + id: string + order_id: string + buyer_id: string + seller_id: string + rating: number + comment: string + created_at: string +} + +export interface ReviewPage { + reviews: Review[] + total: number + page: number + page_size: number +} + +export interface Shipment { + id: string + order_id: string + carrier: string + tracking_code: string + external_tracking: string + status: string + created_at: string + updated_at: string +} + +export interface ShipmentPage { + shipments: Shipment[] + total: number + page: number + page_size: number } diff --git a/seeder-api/pkg/seeder/seeder.go b/seeder-api/pkg/seeder/seeder.go index 674cf47..33ea707 100644 --- a/seeder-api/pkg/seeder/seeder.go +++ b/seeder-api/pkg/seeder/seeder.go @@ -293,6 +293,26 @@ func SeedLean(dsn string) (string, error) { VALUES ('%s', '%s', '%s', '%s', %d, NOW(), NOW())`, orderID, buyerID, sellerID, status, totalCents, )) + + // Create Review if Delivered + if status == "Entregue" { + rating := 3 + rng.Intn(3) // 3-5 stars + comment := []string{"Ótimo atendimento!", "Entrega rápida", "Recomendo", "Tudo certo"}[rng.Intn(4)] + mustExec(db, fmt.Sprintf(`INSERT INTO reviews (id, order_id, buyer_id, seller_id, rating, comment, created_at) + VALUES ('%s', '%s', '%s', '%s', %d, '%s', NOW())`, + uuid.Must(uuid.NewV7()), orderID, buyerID, sellerID, rating, comment, + )) + } + + // Create Shipment if Faturado or Entregue + if status == "Faturado" || status == "Entregue" { + carrier := []string{"Correios", "Loggi", "TotalExpress"}[rng.Intn(3)] + tracking := fmt.Sprintf("TRK-%d%d", rng.Intn(1000), rng.Intn(1000)) + mustExec(db, fmt.Sprintf(`INSERT INTO shipments (id, order_id, carrier, tracking_code, external_tracking, status, created_at, updated_at) + VALUES ('%s', '%s', '%s', '%s', '', 'Enviado', NOW(), NOW())`, + uuid.Must(uuid.NewV7()), orderID, carrier, tracking, + )) + } } log.Printf("✅ [Lean] Created %d orders", numOrders)