feat(marketplace): implement admin dashboard with full CRUD operations
- Add Header component with top navigation menu - Create DashboardLayout with nested routing under /dashboard - Implement Users, Companies, Products, Orders CRUD pages - Add adminService with all API operations - Update apiClient to return data directly with patch support - Fix TypeScript errors in existing pages - Update seeder README with detailed user credentials table - Fix fmt.Sprintf format verb in seeder.go
This commit is contained in:
parent
ebfc72969c
commit
59919cb875
20 changed files with 1778 additions and 139 deletions
|
|
@ -1,39 +1,54 @@
|
|||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { LoginPage } from './pages/Login'
|
||||
import { DashboardPage } from './pages/Dashboard'
|
||||
import { CartPage } from './pages/Cart'
|
||||
import { CheckoutPage } from './pages/Checkout'
|
||||
import { ProfilePage } from './pages/Profile'
|
||||
import { OrdersPage } from './pages/Orders'
|
||||
import { OrdersPage as UserOrdersPage } from './pages/Orders'
|
||||
import { InventoryPage } from './pages/Inventory'
|
||||
import { CompanyPage } from './pages/Company'
|
||||
import { SellerDashboardPage } from './pages/SellerDashboard'
|
||||
import { AdminDashboardPage } from './pages/AdminDashboard'
|
||||
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
||||
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { DashboardLayout } from './layouts/DashboardLayout'
|
||||
import {
|
||||
DashboardHome,
|
||||
UsersPage,
|
||||
CompaniesPage,
|
||||
ProductsPage,
|
||||
OrdersPage
|
||||
} from './pages/admin'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Owner / Seller Dashboard */}
|
||||
{/* Admin Dashboard with Header Layout */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
||||
<DashboardPage />
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<DashboardLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Route index element={<DashboardHome />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="companies" element={<CompaniesPage />} />
|
||||
<Route path="products" element={<ProductsPage />} />
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin Dashboard */}
|
||||
{/* Legacy admin route - redirect to dashboard */}
|
||||
<Route path="/admin" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* Owner / Seller Dashboard */}
|
||||
<Route
|
||||
path="/admin"
|
||||
path="/seller"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<AdminDashboardPage />
|
||||
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
||||
<SellerDashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
|
@ -78,7 +93,7 @@ function App() {
|
|||
path="/orders"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<OrdersPage />
|
||||
<UserOrdersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
82
marketplace/src/components/Header.tsx
Normal file
82
marketplace/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/dashboard', label: 'Início' },
|
||||
{ path: '/dashboard/users', label: 'Usuários' },
|
||||
{ path: '/dashboard/companies', label: 'Empresas' },
|
||||
{ path: '/dashboard/products', label: 'Produtos' },
|
||||
{ path: '/dashboard/orders', label: 'Pedidos' },
|
||||
]
|
||||
|
||||
export function Header() {
|
||||
const { user, logout } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<header className="bg-gradient-to-r from-blue-900 to-blue-700 text-white shadow-lg">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link to="/dashboard" className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/20">
|
||||
<span className="text-xl font-bold">💊</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold">SaveInMed</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path !== '/dashboard' && location.pathname.startsWith(item.path))
|
||||
return (
|
||||
<Link
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 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>
|
||||
<p className="text-xs text-white/70 capitalize">{user?.role}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<nav className="md:hidden border-t border-white/20 px-4 py-2 flex gap-2 overflow-x-auto">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`whitespace-nowrap px-3 py-1.5 rounded-lg text-sm font-medium ${isActive ? 'bg-white/20' : 'text-white/80'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
@ -50,11 +50,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
// Redirect based on role
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
navigate('/admin', { replace: true })
|
||||
navigate('/dashboard', { replace: true })
|
||||
break
|
||||
case 'owner':
|
||||
case 'seller':
|
||||
navigate('/dashboard', { replace: true })
|
||||
navigate('/seller', { replace: true })
|
||||
break
|
||||
case 'employee':
|
||||
navigate('/colaborador', { replace: true })
|
||||
|
|
@ -63,7 +63,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
navigate('/entregas', { replace: true })
|
||||
break
|
||||
default:
|
||||
navigate('/dashboard', { replace: true })
|
||||
navigate('/seller', { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
13
marketplace/src/layouts/DashboardLayout.tsx
Normal file
13
marketplace/src/layouts/DashboardLayout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Outlet } from 'react-router-dom'
|
||||
import { Header } from '../components/Header'
|
||||
|
||||
export function DashboardLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<Header />
|
||||
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,11 +29,11 @@ export function CompanyPage() {
|
|||
const loadCompany = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await apiClient.get('/v1/companies/me')
|
||||
setCompany(response.data)
|
||||
const data = await apiClient.get<Company>('/v1/companies/me')
|
||||
setCompany(data)
|
||||
setForm({
|
||||
corporate_name: response.data.corporate_name,
|
||||
license_number: response.data.license_number
|
||||
corporate_name: data.corporate_name,
|
||||
license_number: data.license_number
|
||||
})
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ export function InventoryPage() {
|
|||
try {
|
||||
setLoading(true)
|
||||
const params = expiringDays ? `?expires_in_days=${expiringDays}` : ''
|
||||
const response = await apiClient.get(`/v1/inventory${params}`)
|
||||
setInventory(response.data || [])
|
||||
const data = await apiClient.get<InventoryItem[]>(`/v1/inventory${params}`)
|
||||
setInventory(data || [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Erro ao carregar estoque')
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export function OrdersPage() {
|
|||
const loadOrders = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await apiClient.get('/v1/orders')
|
||||
setOrders(response.data || [])
|
||||
const data = await apiClient.get<Order[]>('/v1/orders')
|
||||
setOrders(data || [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Erro ao carregar pedidos')
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@ export function SellerDashboardPage() {
|
|||
const loadDashboard = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await apiClient.get('/v1/dashboard/seller')
|
||||
const payload = response.data ?? {}
|
||||
const payload = await apiClient.get<SellerDashboardData>('/v1/dashboard/seller') ?? {}
|
||||
setData({
|
||||
seller_id: payload.seller_id ?? '',
|
||||
total_sales_cents: payload.total_sales_cents ?? 0,
|
||||
|
|
|
|||
335
marketplace/src/pages/admin/CompaniesPage.tsx
Normal file
335
marketplace/src/pages/admin/CompaniesPage.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { adminService, Company, CreateCompanyRequest } from '../../services/adminService'
|
||||
|
||||
export function CompaniesPage() {
|
||||
const [companies, setCompanies] = useState<Company[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingCompany, setEditingCompany] = useState<Company | null>(null)
|
||||
const [formData, setFormData] = useState<CreateCompanyRequest>({
|
||||
cnpj: '',
|
||||
corporate_name: '',
|
||||
category: 'farmacia',
|
||||
license_number: '',
|
||||
latitude: -16.3281,
|
||||
longitude: -48.9530,
|
||||
city: 'Anápolis',
|
||||
state: 'GO'
|
||||
})
|
||||
|
||||
const pageSize = 10
|
||||
|
||||
useEffect(() => {
|
||||
loadCompanies()
|
||||
}, [page])
|
||||
|
||||
const loadCompanies = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminService.listCompanies(page, pageSize)
|
||||
setCompanies(data.tenants || [])
|
||||
setTotal(data.total)
|
||||
} catch (err) {
|
||||
console.error('Error loading companies:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (editingCompany) {
|
||||
await adminService.updateCompany(editingCompany.id, formData)
|
||||
} else {
|
||||
await adminService.createCompany(formData)
|
||||
}
|
||||
setShowModal(false)
|
||||
resetForm()
|
||||
loadCompanies()
|
||||
} catch (err) {
|
||||
console.error('Error saving company:', err)
|
||||
alert('Erro ao salvar empresa')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir esta empresa?')) return
|
||||
try {
|
||||
await adminService.deleteCompany(id)
|
||||
loadCompanies()
|
||||
} catch (err) {
|
||||
console.error('Error deleting company:', err)
|
||||
alert('Erro ao excluir empresa')
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async (id: string) => {
|
||||
try {
|
||||
await adminService.verifyCompany(id)
|
||||
loadCompanies()
|
||||
} catch (err) {
|
||||
console.error('Error verifying company:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (company: Company) => {
|
||||
setEditingCompany(company)
|
||||
setFormData({
|
||||
cnpj: company.cnpj,
|
||||
corporate_name: company.corporate_name,
|
||||
category: company.category,
|
||||
license_number: company.license_number,
|
||||
latitude: company.latitude,
|
||||
longitude: company.longitude,
|
||||
city: company.city,
|
||||
state: company.state
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingCompany(null)
|
||||
setFormData({
|
||||
cnpj: '',
|
||||
corporate_name: '',
|
||||
category: 'farmacia',
|
||||
license_number: '',
|
||||
latitude: -16.3281,
|
||||
longitude: -48.9530,
|
||||
city: 'Anápolis',
|
||||
state: 'GO'
|
||||
})
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm()
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
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">Empresas</h1>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
+ Nova Empresa
|
||||
</button>
|
||||
</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">Razão Social</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">CNPJ</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Categoria</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Cidade</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-medium">Verificada</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">Ações</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>
|
||||
) : companies.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||
Nenhuma empresa encontrada
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
companies.map((company) => (
|
||||
<tr key={company.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{company.corporate_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{company.cnpj}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800 capitalize">
|
||||
{company.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{company.city}/{company.state}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleVerify(company.id)}
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium ${company.is_verified
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{company.is_verified ? '✓ Verificada' : 'Pendente'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => openEdit(company)}
|
||||
className="mr-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(company.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 text-xl font-bold">
|
||||
{editingCompany ? 'Editar Empresa' : 'Nova Empresa'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Razão Social</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.corporate_name}
|
||||
onChange={(e) => setFormData({ ...formData, corporate_name: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">CNPJ</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cnpj}
|
||||
onChange={(e) => setFormData({ ...formData, cnpj: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Categoria</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
>
|
||||
<option value="farmacia">Farmácia</option>
|
||||
<option value="distribuidora">Distribuidora</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Licença Sanitária</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.license_number}
|
||||
onChange={(e) => setFormData({ ...formData, license_number: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Cidade</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.city}
|
||||
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Estado</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.state}
|
||||
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
maxLength={2}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Latitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.latitude}
|
||||
onChange={(e) => setFormData({ ...formData, latitude: parseFloat(e.target.value) })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Longitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.longitude}
|
||||
onChange={(e) => setFormData({ ...formData, longitude: parseFloat(e.target.value) })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded border px-4 py-2 text-sm"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
marketplace/src/pages/admin/DashboardHome.tsx
Normal file
110
marketplace/src/pages/admin/DashboardHome.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number
|
||||
totalCompanies: number
|
||||
totalProducts: number
|
||||
totalOrders: number
|
||||
}
|
||||
|
||||
export function DashboardHome() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalUsers: 0,
|
||||
totalCompanies: 0,
|
||||
totalProducts: 0,
|
||||
totalOrders: 0
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [])
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Load counts from each endpoint
|
||||
const [users, companies, products, orders] = await Promise.all([
|
||||
fetch('/api/v1/users?page=1&page_size=1', {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('mp-auth-user') ? JSON.parse(localStorage.getItem('mp-auth-user')!).token : ''}` }
|
||||
}).then(r => r.json()).catch(() => ({ total: 0 })),
|
||||
fetch('/api/v1/companies?page=1&page_size=1', {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('mp-auth-user') ? JSON.parse(localStorage.getItem('mp-auth-user')!).token : ''}` }
|
||||
}).then(r => r.json()).catch(() => ({ total: 0 })),
|
||||
fetch('/api/v1/products?page=1&page_size=1', {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('mp-auth-user') ? JSON.parse(localStorage.getItem('mp-auth-user')!).token : ''}` }
|
||||
}).then(r => r.json()).catch(() => ({ total: 0 })),
|
||||
fetch('/api/v1/orders?page=1&page_size=1', {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('mp-auth-user') ? JSON.parse(localStorage.getItem('mp-auth-user')!).token : ''}` }
|
||||
}).then(r => r.json()).catch(() => ({ total: 0 }))
|
||||
])
|
||||
|
||||
setStats({
|
||||
totalUsers: users.total || 0,
|
||||
totalCompanies: companies.total || 0,
|
||||
totalProducts: products.total || 0,
|
||||
totalOrders: orders.total || 0
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error loading stats:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{ title: 'Usuários', value: stats.totalUsers, icon: '👥', color: 'from-blue-500 to-blue-600' },
|
||||
{ title: 'Empresas', value: stats.totalCompanies, icon: '🏢', color: 'from-purple-500 to-purple-600' },
|
||||
{ title: 'Produtos', value: stats.totalProducts, icon: '💊', color: 'from-green-500 to-green-600' },
|
||||
{ title: 'Pedidos', value: stats.totalOrders, icon: '📦', color: 'from-orange-500 to-orange-600' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-2xl font-bold text-gray-900">Painel Administrativo</h1>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className={`rounded-xl bg-gradient-to-br ${card.color} p-6 text-white shadow-lg`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/80">{card.title}</p>
|
||||
<p className="mt-1 text-3xl font-bold">
|
||||
{loading ? '...' : card.value.toLocaleString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-4xl opacity-80">{card.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-800">Ações Rápidas</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<a href="/dashboard/users" className="rounded-lg bg-white p-4 shadow hover:shadow-md transition-shadow">
|
||||
<h3 className="font-medium text-gray-900">Gerenciar Usuários</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Criar, editar e remover usuários</p>
|
||||
</a>
|
||||
<a href="/dashboard/companies" className="rounded-lg bg-white p-4 shadow hover:shadow-md transition-shadow">
|
||||
<h3 className="font-medium text-gray-900">Gerenciar Empresas</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Verificar e administrar farmácias</p>
|
||||
</a>
|
||||
<a href="/dashboard/products" className="rounded-lg bg-white p-4 shadow hover:shadow-md transition-shadow">
|
||||
<h3 className="font-medium text-gray-900">Gerenciar Produtos</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Catálogo de medicamentos</p>
|
||||
</a>
|
||||
<a href="/dashboard/orders" className="rounded-lg bg-white p-4 shadow hover:shadow-md transition-shadow">
|
||||
<h3 className="font-medium text-gray-900">Gerenciar Pedidos</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Acompanhar e atualizar status</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
252
marketplace/src/pages/admin/OrdersPage.tsx
Normal file
252
marketplace/src/pages/admin/OrdersPage.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { adminService, Order, OrderItem } from '../../services/adminService'
|
||||
|
||||
const STATUS_OPTIONS = ['Pendente', 'Pago', 'Faturado', 'Entregue']
|
||||
|
||||
export function OrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null)
|
||||
|
||||
const pageSize = 10
|
||||
|
||||
useEffect(() => {
|
||||
loadOrders()
|
||||
}, [page])
|
||||
|
||||
const loadOrders = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminService.listOrders(page, pageSize)
|
||||
setOrders(data.orders || [])
|
||||
setTotal(data.total)
|
||||
} catch (err) {
|
||||
console.error('Error loading orders:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (id: string, status: string) => {
|
||||
try {
|
||||
await adminService.updateOrderStatus(id, status)
|
||||
loadOrders()
|
||||
} catch (err) {
|
||||
console.error('Error updating order status:', err)
|
||||
alert('Erro ao atualizar status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir este pedido?')) return
|
||||
try {
|
||||
await adminService.deleteOrder(id)
|
||||
loadOrders()
|
||||
} catch (err) {
|
||||
console.error('Error deleting order:', err)
|
||||
alert('Erro ao excluir pedido')
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (cents: number) => {
|
||||
return (cents / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
}
|
||||
|
||||
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 getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Pendente': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'Pago': return 'bg-blue-100 text-blue-800'
|
||||
case 'Faturado': return 'bg-purple-100 text-purple-800'
|
||||
case 'Entregue': return 'bg-green-100 text-green-800'
|
||||
default: return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
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">Pedidos</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">ID</th>
|
||||
<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">Status</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">Total</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">Ações</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>
|
||||
) : orders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
Nenhum pedido encontrado
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
orders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-600">
|
||||
{order.id.substring(0, 8)}...
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{formatDate(order.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={order.status}
|
||||
onChange={(e) => handleStatusChange(order.id, e.target.value)}
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium border-0 ${getStatusColor(order.status)}`}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900">
|
||||
{formatPrice(order.total_cents)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
className="mr-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Ver
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(order.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Order Detail Modal */}
|
||||
{selectedOrder && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-xl font-bold">Detalhes do Pedido</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">ID:</span>
|
||||
<p className="font-mono">{selectedOrder.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<p className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(selectedOrder.status)}`}>
|
||||
{selectedOrder.status}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Comprador ID:</span>
|
||||
<p className="font-mono text-xs">{selectedOrder.buyer_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Vendedor ID:</span>
|
||||
<p className="font-mono text-xs">{selectedOrder.seller_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedOrder.items && selectedOrder.items.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium text-gray-700">Itens</h3>
|
||||
<div className="rounded border">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Produto</th>
|
||||
<th className="px-3 py-2 text-right">Qtd</th>
|
||||
<th className="px-3 py-2 text-right">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedOrder.items.map((item, idx) => (
|
||||
<tr key={idx} className="border-t">
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.product_id.substring(0, 8)}...</td>
|
||||
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
||||
<td className="px-3 py-2 text-right">{formatPrice(item.unit_cents * item.quantity)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span>{formatPrice(selectedOrder.total_cents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setSelectedOrder(null)}
|
||||
className="rounded border px-4 py-2 text-sm"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
355
marketplace/src/pages/admin/ProductsPage.tsx
Normal file
355
marketplace/src/pages/admin/ProductsPage.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { adminService, Product, CreateProductRequest, Company } from '../../services/adminService'
|
||||
|
||||
export function ProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [companies, setCompanies] = useState<Company[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null)
|
||||
const [formData, setFormData] = useState<CreateProductRequest>({
|
||||
seller_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
batch: '',
|
||||
expires_at: '',
|
||||
price_cents: 0,
|
||||
stock: 0
|
||||
})
|
||||
|
||||
const pageSize = 10
|
||||
|
||||
useEffect(() => {
|
||||
loadProducts()
|
||||
loadCompanies()
|
||||
}, [page])
|
||||
|
||||
const loadProducts = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminService.listProducts(page, pageSize)
|
||||
setProducts(data.products || [])
|
||||
setTotal(data.total)
|
||||
} catch (err) {
|
||||
console.error('Error loading products:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
const data = await adminService.listCompanies(1, 100)
|
||||
setCompanies(data.tenants || [])
|
||||
} catch (err) {
|
||||
console.error('Error loading companies:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (editingProduct) {
|
||||
await adminService.updateProduct(editingProduct.id, {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
batch: formData.batch,
|
||||
expires_at: formData.expires_at,
|
||||
price_cents: formData.price_cents,
|
||||
stock: formData.stock
|
||||
})
|
||||
} else {
|
||||
await adminService.createProduct(formData)
|
||||
}
|
||||
setShowModal(false)
|
||||
resetForm()
|
||||
loadProducts()
|
||||
} catch (err) {
|
||||
console.error('Error saving product:', err)
|
||||
alert('Erro ao salvar produto')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir este produto?')) return
|
||||
try {
|
||||
await adminService.deleteProduct(id)
|
||||
loadProducts()
|
||||
} catch (err) {
|
||||
console.error('Error deleting product:', err)
|
||||
alert('Erro ao excluir produto')
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (product: Product) => {
|
||||
setEditingProduct(product)
|
||||
setFormData({
|
||||
seller_id: product.seller_id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
batch: product.batch,
|
||||
expires_at: product.expires_at.split('T')[0],
|
||||
price_cents: product.price_cents,
|
||||
stock: product.stock
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingProduct(null)
|
||||
setFormData({
|
||||
seller_id: companies[0]?.id || '',
|
||||
name: '',
|
||||
description: '',
|
||||
batch: '',
|
||||
expires_at: '',
|
||||
price_cents: 0,
|
||||
stock: 0
|
||||
})
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm()
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const formatPrice = (cents: number) => {
|
||||
return (cents / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR')
|
||||
}
|
||||
|
||||
const isExpiringSoon = (dateStr: string) => {
|
||||
const expiresAt = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
return diffDays <= 30
|
||||
}
|
||||
|
||||
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">Produtos</h1>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
+ Novo Produto
|
||||
</button>
|
||||
</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">Produto</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Lote</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Validade</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">Preço</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">Estoque</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">Ações</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>
|
||||
) : products.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||
Nenhum produto encontrado
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-gray-900">{product.name}</div>
|
||||
<div className="text-xs text-gray-500">{product.description}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{product.batch}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-1 text-xs font-medium ${isExpiringSoon(product.expires_at)
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{formatDate(product.expires_at)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900">
|
||||
{formatPrice(product.price_cents)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={`rounded-full px-2 py-1 text-xs font-medium ${product.stock < 10
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{product.stock}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => openEdit(product)}
|
||||
className="mr-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(product.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 text-xl font-bold">
|
||||
{editingProduct ? 'Editar Produto' : 'Novo Produto'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!editingProduct && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Vendedor</label>
|
||||
<select
|
||||
value={formData.seller_id}
|
||||
onChange={(e) => setFormData({ ...formData, seller_id: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{companies.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.corporate_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<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 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Descrição</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Lote</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.batch}
|
||||
onChange={(e) => setFormData({ ...formData, batch: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Validade</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.expires_at}
|
||||
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Preço (R$)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.price_cents / 100}
|
||||
onChange={(e) => setFormData({ ...formData, price_cents: Math.round(parseFloat(e.target.value) * 100) })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Estoque</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.stock}
|
||||
onChange={(e) => setFormData({ ...formData, stock: parseInt(e.target.value) })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded border px-4 py-2 text-sm"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
304
marketplace/src/pages/admin/UsersPage.tsx
Normal file
304
marketplace/src/pages/admin/UsersPage.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { adminService, User, CreateUserRequest, Company } from '../../services/adminService'
|
||||
|
||||
export function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [companies, setCompanies] = useState<Company[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [formData, setFormData] = useState<CreateUserRequest>({
|
||||
company_id: '',
|
||||
role: 'Colaborador',
|
||||
name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const pageSize = 10
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
loadCompanies()
|
||||
}, [page])
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminService.listUsers(page, pageSize)
|
||||
setUsers(data.users || [])
|
||||
setTotal(data.total)
|
||||
} catch (err) {
|
||||
console.error('Error loading users:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
const data = await adminService.listCompanies(1, 100)
|
||||
setCompanies(data.tenants || [])
|
||||
} catch (err) {
|
||||
console.error('Error loading companies:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (editingUser) {
|
||||
await adminService.updateUser(editingUser.id, formData)
|
||||
} else {
|
||||
await adminService.createUser(formData)
|
||||
}
|
||||
setShowModal(false)
|
||||
resetForm()
|
||||
loadUsers()
|
||||
} catch (err) {
|
||||
console.error('Error saving user:', err)
|
||||
alert('Erro ao salvar usuário')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir este usuário?')) return
|
||||
try {
|
||||
await adminService.deleteUser(id)
|
||||
loadUsers()
|
||||
} catch (err) {
|
||||
console.error('Error deleting user:', err)
|
||||
alert('Erro ao excluir usuário')
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setFormData({
|
||||
company_id: user.company_id,
|
||||
role: user.role,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: ''
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingUser(null)
|
||||
setFormData({
|
||||
company_id: companies[0]?.id || '',
|
||||
role: 'Colaborador',
|
||||
name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm()
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
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">Usuários</h1>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
+ Novo Usuário
|
||||
</button>
|
||||
</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">Nome</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Username</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Email</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Função</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">Ações</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>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
Nenhum usuário encontrado
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{user.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{user.username}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => openEdit(user)}
|
||||
className="mr-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-xl font-bold">
|
||||
{editingUser ? 'Editar Usuário' : 'Novo Usuário'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Empresa</label>
|
||||
<select
|
||||
value={formData.company_id}
|
||||
onChange={(e) => setFormData({ ...formData, company_id: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{companies.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.corporate_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Função</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Dono">Dono</option>
|
||||
<option value="Colaborador">Colaborador</option>
|
||||
<option value="Entregador">Entregador</option>
|
||||
</select>
|
||||
</div>
|
||||
<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 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
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 w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Senha {editingUser && '(deixe vazio para manter)'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
required={!editingUser}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded border px-4 py-2 text-sm"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
marketplace/src/pages/admin/index.ts
Normal file
5
marketplace/src/pages/admin/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { UsersPage } from './UsersPage'
|
||||
export { CompaniesPage } from './CompaniesPage'
|
||||
export { ProductsPage } from './ProductsPage'
|
||||
export { OrdersPage } from './OrdersPage'
|
||||
export { DashboardHome } from './DashboardHome'
|
||||
221
marketplace/src/services/adminService.ts
Normal file
221
marketplace/src/services/adminService.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { apiClient } from './apiClient'
|
||||
|
||||
// ================== USERS ==================
|
||||
export interface User {
|
||||
id: string
|
||||
company_id: string
|
||||
role: string
|
||||
name: string
|
||||
username: string
|
||||
email: string
|
||||
email_verified: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UserPage {
|
||||
users: User[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
company_id: string
|
||||
role: string
|
||||
name: string
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
company_id?: string
|
||||
role?: string
|
||||
name?: string
|
||||
username?: string
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
// ================== COMPANIES ==================
|
||||
export interface Company {
|
||||
id: string
|
||||
cnpj: string
|
||||
corporate_name: string
|
||||
category: string
|
||||
license_number: string
|
||||
is_verified: boolean
|
||||
latitude: number
|
||||
longitude: number
|
||||
city: string
|
||||
state: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CompanyPage {
|
||||
tenants: Company[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface CreateCompanyRequest {
|
||||
cnpj: string
|
||||
corporate_name: string
|
||||
category: string
|
||||
license_number: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
city: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface UpdateCompanyRequest {
|
||||
cnpj?: string
|
||||
corporate_name?: string
|
||||
category?: string
|
||||
license_number?: string
|
||||
is_verified?: boolean
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
city?: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
// ================== PRODUCTS ==================
|
||||
export interface Product {
|
||||
id: string
|
||||
seller_id: string
|
||||
name: string
|
||||
description: string
|
||||
batch: string
|
||||
expires_at: string
|
||||
price_cents: number
|
||||
stock: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProductPage {
|
||||
products: Product[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface CreateProductRequest {
|
||||
seller_id: string
|
||||
name: string
|
||||
description: string
|
||||
batch: string
|
||||
expires_at: string
|
||||
price_cents: number
|
||||
stock: number
|
||||
}
|
||||
|
||||
export interface UpdateProductRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
batch?: string
|
||||
expires_at?: string
|
||||
price_cents?: number
|
||||
stock?: number
|
||||
}
|
||||
|
||||
// ================== ORDERS ==================
|
||||
export interface OrderItem {
|
||||
id: string
|
||||
order_id: string
|
||||
product_id: string
|
||||
quantity: number
|
||||
unit_cents: number
|
||||
batch: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
buyer_id: string
|
||||
seller_id: string
|
||||
status: string
|
||||
total_cents: number
|
||||
items: OrderItem[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface OrderPage {
|
||||
orders: Order[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// ================== ADMIN SERVICE ==================
|
||||
export const adminService = {
|
||||
// Users
|
||||
listUsers: (page = 1, pageSize = 20) =>
|
||||
apiClient.get<UserPage>(`/api/v1/users?page=${page}&page_size=${pageSize}`),
|
||||
|
||||
getUser: (id: string) =>
|
||||
apiClient.get<User>(`/api/v1/users/${id}`),
|
||||
|
||||
createUser: (data: CreateUserRequest) =>
|
||||
apiClient.post<User>('/api/v1/users', data),
|
||||
|
||||
updateUser: (id: string, data: UpdateUserRequest) =>
|
||||
apiClient.put<User>(`/api/v1/users/${id}`, data),
|
||||
|
||||
deleteUser: (id: string) =>
|
||||
apiClient.delete(`/api/v1/users/${id}`),
|
||||
|
||||
// Companies
|
||||
listCompanies: (page = 1, pageSize = 20) =>
|
||||
apiClient.get<CompanyPage>(`/api/v1/companies?page=${page}&page_size=${pageSize}`),
|
||||
|
||||
getCompany: (id: string) =>
|
||||
apiClient.get<Company>(`/api/v1/companies/${id}`),
|
||||
|
||||
createCompany: (data: CreateCompanyRequest) =>
|
||||
apiClient.post<Company>('/api/v1/companies', data),
|
||||
|
||||
updateCompany: (id: string, data: UpdateCompanyRequest) =>
|
||||
apiClient.patch<Company>(`/api/v1/companies/${id}`, data),
|
||||
|
||||
deleteCompany: (id: string) =>
|
||||
apiClient.delete(`/api/v1/companies/${id}`),
|
||||
|
||||
verifyCompany: (id: string) =>
|
||||
apiClient.patch<Company>(`/api/v1/companies/${id}/verify`, {}),
|
||||
|
||||
// Products
|
||||
listProducts: (page = 1, pageSize = 20) =>
|
||||
apiClient.get<ProductPage>(`/api/v1/products?page=${page}&page_size=${pageSize}`),
|
||||
|
||||
getProduct: (id: string) =>
|
||||
apiClient.get<Product>(`/api/v1/products/${id}`),
|
||||
|
||||
createProduct: (data: CreateProductRequest) =>
|
||||
apiClient.post<Product>('/api/v1/products', data),
|
||||
|
||||
updateProduct: (id: string, data: UpdateProductRequest) =>
|
||||
apiClient.patch<Product>(`/api/v1/products/${id}`, data),
|
||||
|
||||
deleteProduct: (id: string) =>
|
||||
apiClient.delete(`/api/v1/products/${id}`),
|
||||
|
||||
// Orders
|
||||
listOrders: (page = 1, pageSize = 20) =>
|
||||
apiClient.get<OrderPage>(`/api/v1/orders?page=${page}&page_size=${pageSize}`),
|
||||
|
||||
getOrder: (id: string) =>
|
||||
apiClient.get<Order>(`/api/v1/orders/${id}`),
|
||||
|
||||
updateOrderStatus: (id: string, status: string) =>
|
||||
apiClient.patch(`/api/v1/orders/${id}/status`, { status }),
|
||||
|
||||
deleteOrder: (id: string) =>
|
||||
apiClient.delete(`/api/v1/orders/${id}`),
|
||||
}
|
||||
|
|
@ -29,10 +29,11 @@ instance.interceptors.response.use(
|
|||
)
|
||||
|
||||
export const apiClient = {
|
||||
get: instance.get,
|
||||
post: instance.post,
|
||||
put: instance.put,
|
||||
delete: instance.delete,
|
||||
get: <T>(url: string) => instance.get<T>(url).then(r => r.data),
|
||||
post: <T>(url: string, data?: unknown) => instance.post<T>(url, data).then(r => r.data),
|
||||
put: <T>(url: string, data?: unknown) => instance.put<T>(url, data).then(r => r.data),
|
||||
patch: <T>(url: string, data?: unknown) => instance.patch<T>(url, data).then(r => r.data),
|
||||
delete: (url: string) => instance.delete(url).then(r => r.data),
|
||||
setToken: (value: string | null) => {
|
||||
token = value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,12 @@ export interface AuthLoginPayload {
|
|||
export const authService = {
|
||||
login: async (payload: AuthLoginPayload) => {
|
||||
console.log('🔐 [authService] Making request to /v1/auth/login with:', payload)
|
||||
const response = await apiClient.post<AuthResponse>('/v1/auth/login', payload)
|
||||
console.log('🔐 [authService] Full axios response:', response)
|
||||
console.log('🔐 [authService] response.data:', response.data)
|
||||
console.log('🔐 [authService] response.data.token:', response.data?.token)
|
||||
const { data } = response
|
||||
const data = await apiClient.post<AuthResponse>('/v1/auth/login', payload)
|
||||
console.log('🔐 [authService] Response data:', data)
|
||||
return { token: data.token, expiresAt: data.expires_at }
|
||||
},
|
||||
logout: async () => {
|
||||
await apiClient.post('/v1/auth/logout')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const productService = {
|
|||
if (filters.page) params.append('page', filters.page.toString())
|
||||
if (filters.page_size) params.append('page_size', filters.page_size.toString())
|
||||
|
||||
const response = await apiClient.get(`/v1/products/search?${params.toString()}`)
|
||||
return response.data
|
||||
return apiClient.get<ProductSearchPage>(`/v1/products/search?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,130 +4,79 @@ Microserviço utilitário para popular o banco de dados com dados de teste para
|
|||
|
||||
## ⚠️ AVISO IMPORTANTE
|
||||
|
||||
**Este serviço é DESTRUTIVO!** Ele:
|
||||
**Este serviço é DESTRUTIVO!** Ao rodar qualquer seed:
|
||||
1. **REMOVE** todas as tabelas existentes (`companies`, `products`, `users`, etc.)
|
||||
2. **RECRIA** as tabelas.
|
||||
3. **MODO FULL**: Gera volume, mas **NÃO** cria usuários padrão (apenas estrutura).
|
||||
4. **MODO LEAN**: Gera dados mínimos e **CRIA** usuários padrão (admin, dono, etc.).
|
||||
2. **RECRIA** as tabelas do zero.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Modos de Operação
|
||||
|
||||
### 1. `mode=lean` (Recomendado para Dev)
|
||||
Gera um ambiente funcional com **4 Farmácias** em Anápolis e equipe completa em cada uma:
|
||||
### 1. `mode=lean` (Desenvolvimento)
|
||||
**Fluxo:** Limpa tudo > Cria tabelas > Cria 4 farmácias > **Cria Usuários para Login**.
|
||||
|
||||
#### 1. Farmácia Central (Sufixo 1)
|
||||
- **CNPJ**: 11.111.111/0001-11
|
||||
- **Usuários**: `dono1`, `colab1`, `entregador1` (Senha: 123456)
|
||||
- **Localização**: Centro de Anápolis
|
||||
Use este modo para desenvolvimento diário. Ele cria as seguintes credenciais de acesso:
|
||||
|
||||
#### 2. Farmácia Jundiaí (Sufixo 2)
|
||||
- **CNPJ**: 22.222.222/0001-22
|
||||
- **Usuários**: `dono2`, `colab2`, `entregador2` (Senha: 123456)
|
||||
- **Localização**: Bairro Jundiaí (Norte/Leste)
|
||||
| Farmácia | Função | Usuário | Senha |
|
||||
|---|---|---|---|
|
||||
| **Farmácia Central** | Dono | `dono1` | `123456` |
|
||||
| | Colaborador | `colab1` | `123456` |
|
||||
| | Entregador | `entregador1` | `123456` |
|
||||
| **Farmácia Jundiaí** | Dono | `dono2` | `123456` |
|
||||
| | Colaborador | `colab2` | `123456` |
|
||||
| | Entregador | `entregador2` | `123456` |
|
||||
| **Farmácia Jaiara** | Dono | `dono3` | `123456` |
|
||||
| | Colaborador | `colab3` | `123456` |
|
||||
| | Entregador | `entregador3` | `123456` |
|
||||
| **Farmácia Universitária** | Dono | `dono4` | `123456` |
|
||||
| | Colaborador | `colab4` | `123456` |
|
||||
| | Entregador | `entregador4` | `123456` |
|
||||
| **Global** | **Admin** | `admin` | `admin123` |
|
||||
|
||||
#### 3. Farmácia Jaiara (Sufixo 3)
|
||||
- **CNPJ**: 33.333.333/0001-33
|
||||
- **Usuários**: `dono3`, `colab3`, `entregador3` (Senha: 123456)
|
||||
- **Localização**: Vila Jaiara (Norte/Oeste)
|
||||
### 2. `mode=full` (Teste de Carga)
|
||||
**Fluxo:** Limpa tudo > Cria tabelas > Gera ~400 farmácias e ~85k produtos.
|
||||
|
||||
#### 4. Farmácia Universitária (Sufixo 4)
|
||||
- **CNPJ**: 44.444.444/0001-44
|
||||
- **Usuários**: `dono4`, `colab4`, `entregador4` (Senha: 123456)
|
||||
- **Localização**: Cidade Universitária (Sul/Leste)
|
||||
**⚠️ Importante:** O modo FULL **NÃO** cria usuários de farmácia padrão para login, apenas o admin global pode existir (dependendo da implementação). Use apenas para testar performance de queries geográficas ou listagens massivas.
|
||||
|
||||
#### Admin Global
|
||||
- **Usuário**: `admin` (Senha: admin123)
|
||||
---
|
||||
|
||||
### 2. `mode=full` (Padrão/Load Test)
|
||||
Gera volume de dados:
|
||||
- **400 Farmácias**
|
||||
- **~85.000 Produtos**
|
||||
- **SEM USUÁRIOS** (Tabela criada, mas vazia)
|
||||
- Ideal para testar performance, busca geografia, clusterização no mapa.
|
||||
## 🚀 Como Rodar
|
||||
|
||||
## 🏗️ Arquitetura
|
||||
### Pré-requisitos
|
||||
- PostgreSQL rodando
|
||||
- Go 1.22+
|
||||
|
||||
```
|
||||
seeder-api/
|
||||
├── main.go # Entry point HTTP (POST /seed)
|
||||
├── pkg/
|
||||
├── seeder/
|
||||
│ └── seeder.go # Lógica de geração de dados
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
## Variáveis de Ambiente
|
||||
### 1. Configurar
|
||||
Certifique-se que o `.env` ou variáveis de ambiente estão corretas:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://user:password@host:port/dbname?sslmode=disable
|
||||
PORT=8080 # Porta padrão (pode ser alterada)
|
||||
```
|
||||
|
||||
## 🚀 Uso
|
||||
|
||||
### Executar Localmente
|
||||
|
||||
```bash
|
||||
# Configurar DATABASE_URL
|
||||
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/saveinmed?sslmode=disable
|
||||
export DATABASE_URL=postgres://user:password@localhost:5432/saveinmed?sslmode=disable
|
||||
export PORT=8216
|
||||
```
|
||||
|
||||
# Executar
|
||||
### 2. Executar API
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
### 3. Disparar o Seed (Em outro terminal)
|
||||
|
||||
#### 1. Seeder Rápido (Lean) - Cria Usuários!
|
||||
**Opção A: Resetar e Criar Dados de Dev (Lean)**
|
||||
```bash
|
||||
POST http://localhost:8216/seed?mode=lean
|
||||
|
||||
# Resposta
|
||||
"Lean seed completed. Users: admin, dono, colaborador, entregador (Pass: 123456/admin123)"
|
||||
```
|
||||
|
||||
#### 2. Seeder Completo (Full)
|
||||
```bash
|
||||
POST http://localhost:8216/seed
|
||||
|
||||
# Resposta
|
||||
{
|
||||
"tenants": 400,
|
||||
"products": 85432,
|
||||
"location": "Anápolis, GO"
|
||||
}
|
||||
```
|
||||
|
||||
## ⚡ Fluxo de Uso Recomendado
|
||||
|
||||
1. **Parar o backend principal** (para evitar conflitos de conexão/locks)
|
||||
2. **Executar o seeder**:
|
||||
- `curl -X POST "http://localhost:8216/seed?mode=lean"`
|
||||
3. **Reiniciar o backend**
|
||||
4. A API estará pronta com dados de teste e usuários para login.
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t saveinmed-seeder:latest .
|
||||
|
||||
# Run
|
||||
docker run -p 8216:8216 \
|
||||
-e DATABASE_URL=postgres://user:password@host:5432/saveinmed \
|
||||
-e PORT=8216 \
|
||||
saveinmed-seeder:latest
|
||||
|
||||
# Seed (Lean)
|
||||
curl -X POST "http://localhost:8216/seed?mode=lean"
|
||||
```
|
||||
_Saída esperada:_
|
||||
`"Lean seed completed. 4 Pharmacies. Users: 13. Pass: 123456 (admin: admin123)"`
|
||||
|
||||
## 📝 Notas
|
||||
**Opção B: Resetar e Criar Dados de Carga (Full)**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8216/seed"
|
||||
```
|
||||
|
||||
- Os dados são **regeneráveis** - execute novamente para limpar e recriar.
|
||||
- **NÃO USE EM PRODUÇÃO** - vai apagar todos os dados reais!
|
||||
|
||||
## 📝 Licença
|
||||
|
||||
MIT
|
||||
## ⚡ Fluxo Recomendado de Trabalho
|
||||
1. Pare o backend principal (`backend-go`) para liberar conexões com o banco.
|
||||
2. Rode o seeder: `go run main.go` em um terminal.
|
||||
3. Dispare o curl: `curl -X POST "http://localhost:8216/seed?mode=lean"`.
|
||||
4. Pare o seeder (Ctrl+C).
|
||||
5. Inicie o backend principal novamente.
|
||||
6. Faça login no painel com `dono1` / `123456`.
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ func SeedLean(dsn string) (string, error) {
|
|||
createdUsers = append(createdUsers, "admin (Admin)")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Lean seed completed. 4 Pharmacies. Users: %s. Pass: 123456 (admin: admin123)", len(createdUsers)), nil
|
||||
return fmt.Sprintf("Lean seed completed. 4 Pharmacies. Users: %d. Pass: 123456 (admin: admin123)", len(createdUsers)), nil
|
||||
}
|
||||
|
||||
func SeedFull(dsn string) (string, error) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue