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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
||||
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/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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="companies" element={<CompaniesPage />} />
|
||||
<Route path="products" element={<ProductsPage />} />
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
<Route path="reviews" element={<ReviewsPage />} />
|
||||
<Route path="logistics" element={<LogisticsPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
{/* Legacy admin route - redirect to dashboard */}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
@ -48,7 +50,9 @@ export function Header() {
|
|||
{/* User info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { authService } from '../services/auth'
|
|||
export type UserRole = 'admin' | 'owner' | 'employee' | 'delivery' | 'seller' | 'customer'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
name: string
|
||||
username?: string
|
||||
email?: string
|
||||
role: UserRole
|
||||
token: string
|
||||
}
|
||||
|
|
@ -14,8 +17,9 @@ export interface AuthUser {
|
|||
interface AuthContextValue {
|
||||
user: AuthUser | null
|
||||
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
|
||||
setUser: (user: AuthUser) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
|
|
@ -44,8 +48,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, [user])
|
||||
|
||||
const login = (token: string, role: UserRole, name: string) => {
|
||||
setUser({ token, role, name })
|
||||
const login = (token: string, role: UserRole, name: string, id: string, email?: string, username?: string) => {
|
||||
setUser({ token, role, name, id, email, username })
|
||||
|
||||
// Redirect based on role
|
||||
switch (role) {
|
||||
|
|
@ -78,7 +82,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
user,
|
||||
loading,
|
||||
login,
|
||||
logout
|
||||
logout,
|
||||
setUser
|
||||
}),
|
||||
[user, loading]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -52,13 +52,13 @@ export function LoginPage() {
|
|||
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)
|
||||
|
||||
const role = resolveRole(payload?.role)
|
||||
console.log('🔐 [Login] Role resolved:', role)
|
||||
|
||||
login(token, role, username)
|
||||
login(token, role, username, payload?.sub || '', undefined, username)
|
||||
console.log('🔐 [Login] Login successful!')
|
||||
} catch (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 { OrdersPage } from './OrdersPage'
|
||||
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')
|
||||
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())`,
|
||||
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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue