feat: add admin reviews, logistics, profile pages and update seeder
This commit is contained in:
parent
4f6c96daf0
commit
4ccfa629cc
15 changed files with 700 additions and 11 deletions
|
|
@ -251,6 +251,34 @@ type Shipment struct {
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
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.
|
// OrderStatus enumerates supported transitions.
|
||||||
type OrderStatus string
|
type OrderStatus string
|
||||||
|
|
||||||
|
|
|
||||||
53
backend/internal/http/handler/admin_handler.go
Normal file
53
backend/internal/http/handler/admin_handler.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1090,3 +1090,53 @@ SET active = EXCLUDED.active,
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/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/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", 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/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))
|
mux.Handle("POST /api/v1/auth/register/tenant", chain(http.HandlerFunc(h.RegisterTenant), middleware.Logger, middleware.Gzip))
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,10 @@ type Repository interface {
|
||||||
SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error)
|
SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error)
|
||||||
AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error)
|
AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error)
|
||||||
GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error)
|
GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error)
|
||||||
|
|
||||||
UpsertShippingMethods(ctx context.Context, methods []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.
|
// PaymentGateway abstracts Mercado Pago integration.
|
||||||
|
|
@ -807,3 +810,35 @@ func (s *Service) VerifyCompany(ctx context.Context, id uuid.UUID) (*domain.Comp
|
||||||
}
|
}
|
||||||
return company, nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { LoginPage } from './pages/Login'
|
import { LoginPage } from './pages/Login'
|
||||||
import { CartPage } from './pages/Cart'
|
import { CartPage } from './pages/Cart'
|
||||||
import { CheckoutPage } from './pages/Checkout'
|
import { CheckoutPage } from './pages/Checkout'
|
||||||
import { ProfilePage } from './pages/Profile'
|
|
||||||
import { OrdersPage as UserOrdersPage } from './pages/Orders'
|
import { OrdersPage as UserOrdersPage } from './pages/Orders'
|
||||||
import { InventoryPage } from './pages/Inventory'
|
import { InventoryPage } from './pages/Inventory'
|
||||||
import { CompanyPage } from './pages/Company'
|
import { CompanyPage } from './pages/Company'
|
||||||
|
|
@ -16,7 +15,10 @@ import {
|
||||||
UsersPage,
|
UsersPage,
|
||||||
CompaniesPage,
|
CompaniesPage,
|
||||||
ProductsPage,
|
ProductsPage,
|
||||||
OrdersPage
|
OrdersPage,
|
||||||
|
ReviewsPage,
|
||||||
|
LogisticsPage,
|
||||||
|
ProfilePage
|
||||||
} from './pages/admin'
|
} from './pages/admin'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -38,6 +40,9 @@ function App() {
|
||||||
<Route path="companies" element={<CompaniesPage />} />
|
<Route path="companies" element={<CompaniesPage />} />
|
||||||
<Route path="products" element={<ProductsPage />} />
|
<Route path="products" element={<ProductsPage />} />
|
||||||
<Route path="orders" element={<OrdersPage />} />
|
<Route path="orders" element={<OrdersPage />} />
|
||||||
|
<Route path="reviews" element={<ReviewsPage />} />
|
||||||
|
<Route path="logistics" element={<LogisticsPage />} />
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Legacy admin route - redirect to dashboard */}
|
{/* Legacy admin route - redirect to dashboard */}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ const navItems = [
|
||||||
{ path: '/dashboard/companies', label: 'Empresas' },
|
{ path: '/dashboard/companies', label: 'Empresas' },
|
||||||
{ path: '/dashboard/products', label: 'Produtos' },
|
{ path: '/dashboard/products', label: 'Produtos' },
|
||||||
{ path: '/dashboard/orders', label: 'Pedidos' },
|
{ path: '/dashboard/orders', label: 'Pedidos' },
|
||||||
|
{ path: '/dashboard/reviews', label: 'Avaliações' },
|
||||||
|
{ path: '/dashboard/logistics', label: 'Logística' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
|
@ -35,8 +37,8 @@ export function Header() {
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${isActive
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${isActive
|
||||||
? 'bg-white/20 text-white'
|
? 'bg-white/20 text-white'
|
||||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|
@ -48,7 +50,9 @@ export function Header() {
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="hidden sm:block text-right">
|
<div className="hidden sm:block text-right">
|
||||||
<p className="text-sm font-medium">{user?.name}</p>
|
<Link to="/dashboard/profile" className="text-sm font-medium hover:underline">
|
||||||
|
{user?.name}
|
||||||
|
</Link>
|
||||||
<p className="text-xs text-white/70 capitalize">{user?.role}</p>
|
<p className="text-xs text-white/70 capitalize">{user?.role}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import { authService } from '../services/auth'
|
||||||
export type UserRole = 'admin' | 'owner' | 'employee' | 'delivery' | 'seller' | 'customer'
|
export type UserRole = 'admin' | 'owner' | 'employee' | 'delivery' | 'seller' | 'customer'
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
username?: string
|
||||||
|
email?: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
token: string
|
token: string
|
||||||
}
|
}
|
||||||
|
|
@ -14,8 +17,9 @@ export interface AuthUser {
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
login: (token: string, role: UserRole, name: string) => void
|
login: (token: string, role: UserRole, name: string, id: string, email?: string, username?: string) => void
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
setUser: (user: AuthUser) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||||
|
|
@ -44,8 +48,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
const login = (token: string, role: UserRole, name: string) => {
|
const login = (token: string, role: UserRole, name: string, id: string, email?: string, username?: string) => {
|
||||||
setUser({ token, role, name })
|
setUser({ token, role, name, id, email, username })
|
||||||
|
|
||||||
// Redirect based on role
|
// Redirect based on role
|
||||||
switch (role) {
|
switch (role) {
|
||||||
|
|
@ -78,7 +82,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
logout
|
logout,
|
||||||
|
setUser
|
||||||
}),
|
}),
|
||||||
[user, loading]
|
[user, loading]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,13 @@ export function LoginPage() {
|
||||||
throw new Error('Resposta de login inválida. Verifique o usuário e a senha.')
|
throw new Error('Resposta de login inválida. Verifique o usuário e a senha.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = decodeJwtPayload<{ role?: string }>(token)
|
const payload = decodeJwtPayload<{ role?: string, sub: string }>(token)
|
||||||
console.log('🔐 [Login] JWT payload decoded:', payload)
|
console.log('🔐 [Login] JWT payload decoded:', payload)
|
||||||
|
|
||||||
const role = resolveRole(payload?.role)
|
const role = resolveRole(payload?.role)
|
||||||
console.log('🔐 [Login] Role resolved:', role)
|
console.log('🔐 [Login] Role resolved:', role)
|
||||||
|
|
||||||
login(token, role, username)
|
login(token, role, username, payload?.sub || '', undefined, username)
|
||||||
console.log('🔐 [Login] Login successful!')
|
console.log('🔐 [Login] Login successful!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('🔐 [Login] ERROR caught:', error)
|
console.error('🔐 [Login] ERROR caught:', error)
|
||||||
|
|
|
||||||
140
marketplace/src/pages/admin/LogisticsPage.tsx
Normal file
140
marketplace/src/pages/admin/LogisticsPage.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { adminService, Shipment } from '../../services/adminService'
|
||||||
|
|
||||||
|
export function LogisticsPage() {
|
||||||
|
const [shipments, setShipments] = useState<Shipment[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
const pageSize = 10
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadShipments()
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
const loadShipments = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminService.listShipments(page, pageSize)
|
||||||
|
setShipments(data.shipments || [])
|
||||||
|
setTotal(data.total)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading shipments:', 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 getStatusBadge = (status: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
'Enviado': 'bg-blue-100 text-blue-800',
|
||||||
|
'Entregue': 'bg-green-100 text-green-800',
|
||||||
|
'Pendente': 'bg-yellow-100 text-yellow-800',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={`rounded-full px-2 py-1 text-xs font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Logística de Envios</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-blue-900 text-white">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Data</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Transportadora</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Rastreio</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">External</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Pedido ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||||
|
Carregando...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : shipments.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||||
|
Nenhum envio encontrado
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
shipments.map((shipment) => (
|
||||||
|
<tr key={shipment.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{formatDate(shipment.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">
|
||||||
|
{shipment.carrier}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-blue-600">
|
||||||
|
{shipment.tracking_code}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">
|
||||||
|
{shipment.external_tracking || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{getStatusBadge(shipment.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs font-mono text-gray-500">
|
||||||
|
{shipment.order_id.substring(0, 8)}...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Mostrando {(page - 1) * pageSize + 1} a {Math.min(page * pageSize, total)} de {total}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Próxima
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
marketplace/src/pages/admin/ProfilePage.tsx
Normal file
156
marketplace/src/pages/admin/ProfilePage.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<h1 className="mb-6 text-2xl font-bold text-gray-900">Meu Perfil</h1>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow">
|
||||||
|
{message && (
|
||||||
|
<div className={`mb-4 rounded p-3 text-sm ${message.includes('Erro') ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Nome</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Usuário</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={e => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={e => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<h3 className="mb-2 text-sm font-medium text-gray-900">Alterar Senha</h3>
|
||||||
|
<p className="mb-3 text-xs text-gray-500">Deixe em branco para manter a senha atual</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Nova Senha</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Confirmar Nova Senha</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-blue-300"
|
||||||
|
>
|
||||||
|
{loading ? 'Salvando...' : 'Salvar Alterações'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
marketplace/src/pages/admin/ReviewsPage.tsx
Normal file
135
marketplace/src/pages/admin/ReviewsPage.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { adminService, Review } from '../../services/adminService'
|
||||||
|
|
||||||
|
export function ReviewsPage() {
|
||||||
|
const [reviews, setReviews] = useState<Review[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="flex text-yellow-500">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i < rating ? '★' : '☆'}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Avaliações</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-blue-900 text-white">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Data</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Pedido</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Nota</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Comentário</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Comprador ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||||
|
Carregando...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : reviews.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||||
|
Nenhuma avaliação encontrada
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
reviews.map((review) => (
|
||||||
|
<tr key={review.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{formatDate(review.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-gray-600">
|
||||||
|
{review.order_id.substring(0, 8)}...
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{renderStars(review.rating)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">
|
||||||
|
{review.comment}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs font-mono text-gray-500">
|
||||||
|
{review.buyer_id.substring(0, 8)}...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Mostrando {(page - 1) * pageSize + 1} a {Math.min(page * pageSize, total)} de {total}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Próxima
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,3 +3,6 @@ export { CompaniesPage } from './CompaniesPage'
|
||||||
export { ProductsPage } from './ProductsPage'
|
export { ProductsPage } from './ProductsPage'
|
||||||
export { OrdersPage } from './OrdersPage'
|
export { OrdersPage } from './OrdersPage'
|
||||||
export { DashboardHome } from './DashboardHome'
|
export { DashboardHome } from './DashboardHome'
|
||||||
|
export { ReviewsPage } from './ReviewsPage'
|
||||||
|
export { LogisticsPage } from './LogisticsPage'
|
||||||
|
export { ProfilePage } from './ProfilePage'
|
||||||
|
|
|
||||||
|
|
@ -302,5 +302,57 @@ export const adminService = {
|
||||||
log('deleteOrder done')
|
log('deleteOrder done')
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ================== REVIEWS ==================
|
||||||
|
listReviews: async (page = 1, pageSize = 20) => {
|
||||||
|
log('listReviews', { page, pageSize })
|
||||||
|
const result = await apiClient.get<ReviewPage>(`/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<ShipmentPage>(`/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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,26 @@ func SeedLean(dsn string) (string, error) {
|
||||||
VALUES ('%s', '%s', '%s', '%s', %d, NOW(), NOW())`,
|
VALUES ('%s', '%s', '%s', '%s', %d, NOW(), NOW())`,
|
||||||
orderID, buyerID, sellerID, status, totalCents,
|
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)
|
log.Printf("✅ [Lean] Created %d orders", numOrders)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue