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:
Tiago Yamamoto 2025-12-22 07:22:01 -03:00
parent ebfc72969c
commit 59919cb875
20 changed files with 1778 additions and 139 deletions

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

View 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'

View 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}`),
}

View file

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

View file

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

View file

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

View file

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

View file

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