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 { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { LoginPage } from './pages/Login'
|
import { LoginPage } from './pages/Login'
|
||||||
import { DashboardPage } from './pages/Dashboard'
|
|
||||||
import { CartPage } from './pages/Cart'
|
import { CartPage } from './pages/Cart'
|
||||||
import { CheckoutPage } from './pages/Checkout'
|
import { CheckoutPage } from './pages/Checkout'
|
||||||
import { ProfilePage } from './pages/Profile'
|
import { ProfilePage } from './pages/Profile'
|
||||||
import { OrdersPage } from './pages/Orders'
|
import { OrdersPage as UserOrdersPage } from './pages/Orders'
|
||||||
import { InventoryPage } from './pages/Inventory'
|
import { InventoryPage } from './pages/Inventory'
|
||||||
import { CompanyPage } from './pages/Company'
|
import { CompanyPage } from './pages/Company'
|
||||||
import { SellerDashboardPage } from './pages/SellerDashboard'
|
import { SellerDashboardPage } from './pages/SellerDashboard'
|
||||||
import { AdminDashboardPage } from './pages/AdminDashboard'
|
|
||||||
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
||||||
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
|
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
|
import { DashboardLayout } from './layouts/DashboardLayout'
|
||||||
|
import {
|
||||||
|
DashboardHome,
|
||||||
|
UsersPage,
|
||||||
|
CompaniesPage,
|
||||||
|
ProductsPage,
|
||||||
|
OrdersPage
|
||||||
|
} from './pages/admin'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
{/* Owner / Seller Dashboard */}
|
{/* Admin Dashboard with Header Layout */}
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
<ProtectedRoute allowedRoles={['admin']}>
|
||||||
<DashboardPage />
|
<DashboardLayout />
|
||||||
</ProtectedRoute>
|
</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
|
<Route
|
||||||
path="/admin"
|
path="/seller"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['admin']}>
|
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
||||||
<AdminDashboardPage />
|
<SellerDashboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -78,7 +93,7 @@ function App() {
|
||||||
path="/orders"
|
path="/orders"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<OrdersPage />
|
<UserOrdersPage />
|
||||||
</ProtectedRoute>
|
</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
|
// Redirect based on role
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
navigate('/admin', { replace: true })
|
navigate('/dashboard', { replace: true })
|
||||||
break
|
break
|
||||||
case 'owner':
|
case 'owner':
|
||||||
case 'seller':
|
case 'seller':
|
||||||
navigate('/dashboard', { replace: true })
|
navigate('/seller', { replace: true })
|
||||||
break
|
break
|
||||||
case 'employee':
|
case 'employee':
|
||||||
navigate('/colaborador', { replace: true })
|
navigate('/colaborador', { replace: true })
|
||||||
|
|
@ -63,7 +63,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
navigate('/entregas', { replace: true })
|
navigate('/entregas', { replace: true })
|
||||||
break
|
break
|
||||||
default:
|
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 () => {
|
const loadCompany = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await apiClient.get('/v1/companies/me')
|
const data = await apiClient.get<Company>('/v1/companies/me')
|
||||||
setCompany(response.data)
|
setCompany(data)
|
||||||
setForm({
|
setForm({
|
||||||
corporate_name: response.data.corporate_name,
|
corporate_name: data.corporate_name,
|
||||||
license_number: response.data.license_number
|
license_number: data.license_number
|
||||||
})
|
})
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ export function InventoryPage() {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const params = expiringDays ? `?expires_in_days=${expiringDays}` : ''
|
const params = expiringDays ? `?expires_in_days=${expiringDays}` : ''
|
||||||
const response = await apiClient.get(`/v1/inventory${params}`)
|
const data = await apiClient.get<InventoryItem[]>(`/v1/inventory${params}`)
|
||||||
setInventory(response.data || [])
|
setInventory(data || [])
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Erro ao carregar estoque')
|
setError('Erro ao carregar estoque')
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ export function OrdersPage() {
|
||||||
const loadOrders = async () => {
|
const loadOrders = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await apiClient.get('/v1/orders')
|
const data = await apiClient.get<Order[]>('/v1/orders')
|
||||||
setOrders(response.data || [])
|
setOrders(data || [])
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Erro ao carregar pedidos')
|
setError('Erro ao carregar pedidos')
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,7 @@ export function SellerDashboardPage() {
|
||||||
const loadDashboard = async () => {
|
const loadDashboard = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await apiClient.get('/v1/dashboard/seller')
|
const payload = await apiClient.get<SellerDashboardData>('/v1/dashboard/seller') ?? {}
|
||||||
const payload = response.data ?? {}
|
|
||||||
setData({
|
setData({
|
||||||
seller_id: payload.seller_id ?? '',
|
seller_id: payload.seller_id ?? '',
|
||||||
total_sales_cents: payload.total_sales_cents ?? 0,
|
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 = {
|
export const apiClient = {
|
||||||
get: instance.get,
|
get: <T>(url: string) => instance.get<T>(url).then(r => r.data),
|
||||||
post: instance.post,
|
post: <T>(url: string, data?: unknown) => instance.post<T>(url, data).then(r => r.data),
|
||||||
put: instance.put,
|
put: <T>(url: string, data?: unknown) => instance.put<T>(url, data).then(r => r.data),
|
||||||
delete: instance.delete,
|
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) => {
|
setToken: (value: string | null) => {
|
||||||
token = value
|
token = value
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,12 @@ export interface AuthLoginPayload {
|
||||||
export const authService = {
|
export const authService = {
|
||||||
login: async (payload: AuthLoginPayload) => {
|
login: async (payload: AuthLoginPayload) => {
|
||||||
console.log('🔐 [authService] Making request to /v1/auth/login with:', payload)
|
console.log('🔐 [authService] Making request to /v1/auth/login with:', payload)
|
||||||
const response = await apiClient.post<AuthResponse>('/v1/auth/login', payload)
|
const data = await apiClient.post<AuthResponse>('/v1/auth/login', payload)
|
||||||
console.log('🔐 [authService] Full axios response:', response)
|
console.log('🔐 [authService] Response data:', data)
|
||||||
console.log('🔐 [authService] response.data:', response.data)
|
|
||||||
console.log('🔐 [authService] response.data.token:', response.data?.token)
|
|
||||||
const { data } = response
|
|
||||||
return { token: data.token, expiresAt: data.expires_at }
|
return { token: data.token, expiresAt: data.expires_at }
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
await apiClient.post('/v1/auth/logout')
|
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) params.append('page', filters.page.toString())
|
||||||
if (filters.page_size) params.append('page_size', filters.page_size.toString())
|
if (filters.page_size) params.append('page_size', filters.page_size.toString())
|
||||||
|
|
||||||
const response = await apiClient.get(`/v1/products/search?${params.toString()}`)
|
return apiClient.get<ProductSearchPage>(`/v1/products/search?${params.toString()}`)
|
||||||
return response.data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,130 +4,79 @@ Microserviço utilitário para popular o banco de dados com dados de teste para
|
||||||
|
|
||||||
## ⚠️ AVISO IMPORTANTE
|
## ⚠️ 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.)
|
1. **REMOVE** todas as tabelas existentes (`companies`, `products`, `users`, etc.)
|
||||||
2. **RECRIA** as tabelas.
|
2. **RECRIA** as tabelas do zero.
|
||||||
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.).
|
---
|
||||||
|
|
||||||
## 🎯 Modos de Operação
|
## 🎯 Modos de Operação
|
||||||
|
|
||||||
### 1. `mode=lean` (Recomendado para Dev)
|
### 1. `mode=lean` (Desenvolvimento)
|
||||||
Gera um ambiente funcional com **4 Farmácias** em Anápolis e equipe completa em cada uma:
|
**Fluxo:** Limpa tudo > Cria tabelas > Cria 4 farmácias > **Cria Usuários para Login**.
|
||||||
|
|
||||||
#### 1. Farmácia Central (Sufixo 1)
|
Use este modo para desenvolvimento diário. Ele cria as seguintes credenciais de acesso:
|
||||||
- **CNPJ**: 11.111.111/0001-11
|
|
||||||
- **Usuários**: `dono1`, `colab1`, `entregador1` (Senha: 123456)
|
|
||||||
- **Localização**: Centro de Anápolis
|
|
||||||
|
|
||||||
#### 2. Farmácia Jundiaí (Sufixo 2)
|
| Farmácia | Função | Usuário | Senha |
|
||||||
- **CNPJ**: 22.222.222/0001-22
|
|---|---|---|---|
|
||||||
- **Usuários**: `dono2`, `colab2`, `entregador2` (Senha: 123456)
|
| **Farmácia Central** | Dono | `dono1` | `123456` |
|
||||||
- **Localização**: Bairro Jundiaí (Norte/Leste)
|
| | 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)
|
### 2. `mode=full` (Teste de Carga)
|
||||||
- **CNPJ**: 33.333.333/0001-33
|
**Fluxo:** Limpa tudo > Cria tabelas > Gera ~400 farmácias e ~85k produtos.
|
||||||
- **Usuários**: `dono3`, `colab3`, `entregador3` (Senha: 123456)
|
|
||||||
- **Localização**: Vila Jaiara (Norte/Oeste)
|
|
||||||
|
|
||||||
#### 4. Farmácia Universitária (Sufixo 4)
|
**⚠️ 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.
|
||||||
- **CNPJ**: 44.444.444/0001-44
|
|
||||||
- **Usuários**: `dono4`, `colab4`, `entregador4` (Senha: 123456)
|
|
||||||
- **Localização**: Cidade Universitária (Sul/Leste)
|
|
||||||
|
|
||||||
#### Admin Global
|
---
|
||||||
- **Usuário**: `admin` (Senha: admin123)
|
|
||||||
|
|
||||||
### 2. `mode=full` (Padrão/Load Test)
|
## 🚀 Como Rodar
|
||||||
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.
|
|
||||||
|
|
||||||
## 🏗️ Arquitetura
|
### Pré-requisitos
|
||||||
|
- PostgreSQL rodando
|
||||||
|
- Go 1.22+
|
||||||
|
|
||||||
```
|
### 1. Configurar
|
||||||
seeder-api/
|
Certifique-se que o `.env` ou variáveis de ambiente estão corretas:
|
||||||
├── 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL=postgres://user:password@host:port/dbname?sslmode=disable
|
export DATABASE_URL=postgres://user:password@localhost:5432/saveinmed?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 PORT=8216
|
export PORT=8216
|
||||||
|
```
|
||||||
|
|
||||||
# Executar
|
### 2. Executar API
|
||||||
|
```bash
|
||||||
go run main.go
|
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
|
```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"
|
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.
|
## ⚡ Fluxo Recomendado de Trabalho
|
||||||
- **NÃO USE EM PRODUÇÃO** - vai apagar todos os dados reais!
|
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.
|
||||||
## 📝 Licença
|
3. Dispare o curl: `curl -X POST "http://localhost:8216/seed?mode=lean"`.
|
||||||
|
4. Pare o seeder (Ctrl+C).
|
||||||
MIT
|
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)")
|
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) {
|
func SeedFull(dsn string) (string, error) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue