From 59919cb87536c6c26564304930e836e1774f20a5 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Mon, 22 Dec 2025 07:22:01 -0300 Subject: [PATCH] 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 --- marketplace/src/App.tsx | 39 +- marketplace/src/components/Header.tsx | 82 ++++ marketplace/src/context/AuthContext.tsx | 6 +- marketplace/src/layouts/DashboardLayout.tsx | 13 + marketplace/src/pages/Company.tsx | 8 +- marketplace/src/pages/Inventory.tsx | 4 +- marketplace/src/pages/Orders.tsx | 4 +- marketplace/src/pages/SellerDashboard.tsx | 3 +- marketplace/src/pages/admin/CompaniesPage.tsx | 335 +++++++++++++++++ marketplace/src/pages/admin/DashboardHome.tsx | 110 ++++++ marketplace/src/pages/admin/OrdersPage.tsx | 252 +++++++++++++ marketplace/src/pages/admin/ProductsPage.tsx | 355 ++++++++++++++++++ marketplace/src/pages/admin/UsersPage.tsx | 304 +++++++++++++++ marketplace/src/pages/admin/index.ts | 5 + marketplace/src/services/adminService.ts | 221 +++++++++++ marketplace/src/services/apiClient.ts | 9 +- marketplace/src/services/auth.ts | 8 +- marketplace/src/services/productService.ts | 4 +- seeder-api/README.md | 153 +++----- seeder-api/pkg/seeder/seeder.go | 2 +- 20 files changed, 1778 insertions(+), 139 deletions(-) create mode 100644 marketplace/src/components/Header.tsx create mode 100644 marketplace/src/layouts/DashboardLayout.tsx create mode 100644 marketplace/src/pages/admin/CompaniesPage.tsx create mode 100644 marketplace/src/pages/admin/DashboardHome.tsx create mode 100644 marketplace/src/pages/admin/OrdersPage.tsx create mode 100644 marketplace/src/pages/admin/ProductsPage.tsx create mode 100644 marketplace/src/pages/admin/UsersPage.tsx create mode 100644 marketplace/src/pages/admin/index.ts create mode 100644 marketplace/src/services/adminService.ts diff --git a/marketplace/src/App.tsx b/marketplace/src/App.tsx index 723cec0..be2a8de 100644 --- a/marketplace/src/App.tsx +++ b/marketplace/src/App.tsx @@ -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 ( } /> - {/* Owner / Seller Dashboard */} + {/* Admin Dashboard with Header Layout */} - + + } - /> + > + } /> + } /> + } /> + } /> + } /> + - {/* Admin Dashboard */} + {/* Legacy admin route - redirect to dashboard */} + } /> + + {/* Owner / Seller Dashboard */} - + + } /> @@ -78,7 +93,7 @@ function App() { path="/orders" element={ - + } /> diff --git a/marketplace/src/components/Header.tsx b/marketplace/src/components/Header.tsx new file mode 100644 index 0000000..7ac2bb6 --- /dev/null +++ b/marketplace/src/components/Header.tsx @@ -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 ( +
+
+
+ {/* Logo */} + +
+ 💊 +
+ SaveInMed + + + {/* Navigation */} + + + {/* User info */} +
+
+

{user?.name}

+

{user?.role}

+
+ +
+
+
+ + {/* Mobile Navigation */} + +
+ ) +} diff --git a/marketplace/src/context/AuthContext.tsx b/marketplace/src/context/AuthContext.tsx index 420572b..a0ce785 100644 --- a/marketplace/src/context/AuthContext.tsx +++ b/marketplace/src/context/AuthContext.tsx @@ -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 }) } } diff --git a/marketplace/src/layouts/DashboardLayout.tsx b/marketplace/src/layouts/DashboardLayout.tsx new file mode 100644 index 0000000..892d684 --- /dev/null +++ b/marketplace/src/layouts/DashboardLayout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom' +import { Header } from '../components/Header' + +export function DashboardLayout() { + return ( +
+
+
+ +
+
+ ) +} diff --git a/marketplace/src/pages/Company.tsx b/marketplace/src/pages/Company.tsx index 5a356bf..2fd103c 100644 --- a/marketplace/src/pages/Company.tsx +++ b/marketplace/src/pages/Company.tsx @@ -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('/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) { diff --git a/marketplace/src/pages/Inventory.tsx b/marketplace/src/pages/Inventory.tsx index d55eb0a..92aaf6c 100644 --- a/marketplace/src/pages/Inventory.tsx +++ b/marketplace/src/pages/Inventory.tsx @@ -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(`/v1/inventory${params}`) + setInventory(data || []) setError(null) } catch (err) { setError('Erro ao carregar estoque') diff --git a/marketplace/src/pages/Orders.tsx b/marketplace/src/pages/Orders.tsx index 90f7ac3..ef09508 100644 --- a/marketplace/src/pages/Orders.tsx +++ b/marketplace/src/pages/Orders.tsx @@ -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('/v1/orders') + setOrders(data || []) setError(null) } catch (err) { setError('Erro ao carregar pedidos') diff --git a/marketplace/src/pages/SellerDashboard.tsx b/marketplace/src/pages/SellerDashboard.tsx index 89257ff..918f2cd 100644 --- a/marketplace/src/pages/SellerDashboard.tsx +++ b/marketplace/src/pages/SellerDashboard.tsx @@ -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('/v1/dashboard/seller') ?? {} setData({ seller_id: payload.seller_id ?? '', total_sales_cents: payload.total_sales_cents ?? 0, diff --git a/marketplace/src/pages/admin/CompaniesPage.tsx b/marketplace/src/pages/admin/CompaniesPage.tsx new file mode 100644 index 0000000..174cf73 --- /dev/null +++ b/marketplace/src/pages/admin/CompaniesPage.tsx @@ -0,0 +1,335 @@ +import { useEffect, useState } from 'react' +import { adminService, Company, CreateCompanyRequest } from '../../services/adminService' + +export function CompaniesPage() { + const [companies, setCompanies] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + const [showModal, setShowModal] = useState(false) + const [editingCompany, setEditingCompany] = useState(null) + const [formData, setFormData] = useState({ + 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 ( +
+
+

Empresas

+ +
+ + {/* Table */} +
+ + + + + + + + + + + + + {loading ? ( + + + + ) : companies.length === 0 ? ( + + + + ) : ( + companies.map((company) => ( + + + + + + + + + )) + )} + +
Razão SocialCNPJCategoriaCidadeVerificadaAções
+ Carregando... +
+ Nenhuma empresa encontrada +
{company.corporate_name}{company.cnpj} + + {company.category} + + {company.city}/{company.state} + + + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Mostrando {(page - 1) * pageSize + 1} a {Math.min(page * pageSize, total)} de {total} +

+
+ + +
+
+ )} + + {/* Modal */} + {showModal && ( +
+
+

+ {editingCompany ? 'Editar Empresa' : 'Nova Empresa'} +

+
+
+
+ + setFormData({ ...formData, corporate_name: e.target.value })} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ + setFormData({ ...formData, cnpj: e.target.value })} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ + +
+
+ + setFormData({ ...formData, license_number: e.target.value })} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ + setFormData({ ...formData, city: e.target.value })} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ + setFormData({ ...formData, state: e.target.value })} + className="mt-1 w-full rounded border px-3 py-2" + maxLength={2} + required + /> +
+
+ + setFormData({ ...formData, latitude: parseFloat(e.target.value) })} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ + setFormData({ ...formData, longitude: parseFloat(e.target.value) })} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/marketplace/src/pages/admin/DashboardHome.tsx b/marketplace/src/pages/admin/DashboardHome.tsx new file mode 100644 index 0000000..69f6649 --- /dev/null +++ b/marketplace/src/pages/admin/DashboardHome.tsx @@ -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({ + 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 ( +
+

Painel Administrativo

+ + {/* Stats Cards */} +
+ {cards.map((card) => ( +
+
+
+

{card.title}

+

+ {loading ? '...' : card.value.toLocaleString('pt-BR')} +

+
+ {card.icon} +
+
+ ))} +
+ + {/* Quick Actions */} + +
+ ) +} diff --git a/marketplace/src/pages/admin/OrdersPage.tsx b/marketplace/src/pages/admin/OrdersPage.tsx new file mode 100644 index 0000000..f4264ee --- /dev/null +++ b/marketplace/src/pages/admin/OrdersPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + const [selectedOrder, setSelectedOrder] = useState(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 ( +
+
+

Pedidos

+
+ + {/* Table */} +
+ + + + + + + + + + + + {loading ? ( + + + + ) : orders.length === 0 ? ( + + + + ) : ( + orders.map((order) => ( + + + + + + + + )) + )} + +
IDDataStatusTotalAções
+ Carregando... +
+ Nenhum pedido encontrado +
+ {order.id.substring(0, 8)}... + + {formatDate(order.created_at)} + + + + {formatPrice(order.total_cents)} + + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Mostrando {(page - 1) * pageSize + 1} a {Math.min(page * pageSize, total)} de {total} +

+
+ + +
+
+ )} + + {/* Order Detail Modal */} + {selectedOrder && ( +
+
+

Detalhes do Pedido

+
+
+
+ ID: +

{selectedOrder.id}

+
+
+ Status: +

+ {selectedOrder.status} +

+
+
+ Comprador ID: +

{selectedOrder.buyer_id}

+
+
+ Vendedor ID: +

{selectedOrder.seller_id}

+
+
+ + {selectedOrder.items && selectedOrder.items.length > 0 && ( +
+

Itens

+
+ + + + + + + + + + {selectedOrder.items.map((item, idx) => ( + + + + + + ))} + +
ProdutoQtdValor
{item.product_id.substring(0, 8)}...{item.quantity}{formatPrice(item.unit_cents * item.quantity)}
+
+
+ )} + +
+
+ Total: + {formatPrice(selectedOrder.total_cents)} +
+
+
+
+ +
+
+
+ )} +
+ ) +} diff --git a/marketplace/src/pages/admin/ProductsPage.tsx b/marketplace/src/pages/admin/ProductsPage.tsx new file mode 100644 index 0000000..7cd2995 --- /dev/null +++ b/marketplace/src/pages/admin/ProductsPage.tsx @@ -0,0 +1,355 @@ +import { useEffect, useState } from 'react' +import { adminService, Product, CreateProductRequest, Company } from '../../services/adminService' + +export function ProductsPage() { + const [products, setProducts] = useState([]) + const [companies, setCompanies] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + const [showModal, setShowModal] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + const [formData, setFormData] = useState({ + 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 ( +
+
+

Produtos

+ +
+ + {/* Table */} +
+ + + + + + + + + + + + + {loading ? ( + + + + ) : products.length === 0 ? ( + + + + ) : ( + products.map((product) => ( + + + + + + + + + )) + )} + +
ProdutoLoteValidadePreçoEstoqueAções
+ Carregando... +
+ Nenhum produto encontrado +
+
{product.name}
+
{product.description}
+
{product.batch} + + {formatDate(product.expires_at)} + + + {formatPrice(product.price_cents)} + + + {product.stock} + + + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Mostrando {(page - 1) * pageSize + 1} a {Math.min(page * pageSize, total)} de {total} +

+
+ + +
+
+ )} + + {/* Modal */} + {showModal && ( +
+
+

+ {editingProduct ? 'Editar Produto' : 'Novo Produto'} +

+
+ {!editingProduct && ( +
+ + +
+ )} +
+ + setFormData({ ...formData, name: e.target.value })} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ +