Merge branch 'dev' into task4

This commit is contained in:
Andre F. Rodrigues 2026-01-26 15:29:09 -03:00 committed by GitHub
commit 89938f8c1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 395 additions and 203 deletions

View file

@ -54,6 +54,9 @@ type User struct {
Email string `db:"email" json:"email"` Email string `db:"email" json:"email"`
EmailVerified bool `db:"email_verified" json:"email_verified"` EmailVerified bool `db:"email_verified" json:"email_verified"`
PasswordHash string `db:"password_hash" json:"-"` PasswordHash string `db:"password_hash" json:"-"`
Superadmin bool `db:"superadmin" json:"superadmin"`
NomeSocial string `db:"nome_social" json:"nome_social"`
CPF string `db:"cpf" json:"cpf"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
} }

View file

@ -22,6 +22,9 @@ type createUserRequest struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
Superadmin bool `json:"superadmin"`
NomeSocial string `json:"nome-social"`
CPF string `json:"cpf"`
} }
type registerAuthRequest struct { type registerAuthRequest struct {

View file

@ -170,6 +170,10 @@ func (m *MockRepository) CreateInventoryItem(ctx context.Context, item *domain.I
return nil return nil
} }
func (m *MockRepository) UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) error {
return nil
}
func (m *MockRepository) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error) { func (m *MockRepository) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error) {
return []domain.InventoryItem{}, 0, nil return []domain.InventoryItem{}, 0, nil
} }

View file

@ -434,13 +434,58 @@ type registerInventoryRequest struct {
ProductID string `json:"product_id"` ProductID string `json:"product_id"`
SellerID string `json:"seller_id"` SellerID string `json:"seller_id"`
SalePriceCents int64 `json:"sale_price_cents"` SalePriceCents int64 `json:"sale_price_cents"`
OriginalPriceCents int64 `json:"original_price_cents"` // Added to fix backend error
FinalPriceCents int64 `json:"final_price_cents"` // Optional explicit field
StockQuantity int64 `json:"stock_quantity"` StockQuantity int64 `json:"stock_quantity"`
ExpiresAt string `json:"expires_at"` // ISO8601 ExpiresAt string `json:"expires_at"` // ISO8601
Observations string `json:"observations"` Observations string `json:"observations"`
} }
type updateInventoryRequest struct {
StockQuantity int64 `json:"qtdade_estoque"`
SalePrice float64 `json:"preco_venda"`
}
// UpdateInventoryItem updates price and stock for an existing inventory item.
func (h *Handler) UpdateInventoryItem(w http.ResponseWriter, r *http.Request) {
// Parse ID from path
idStr := r.PathValue("id")
if idStr == "" {
parts := splitPath(r.URL.Path)
if len(parts) > 0 {
idStr = parts[len(parts)-1]
}
}
if idStr == "" {
writeError(w, http.StatusBadRequest, errors.New("id is required"))
return
}
itemID, err := uuid.FromString(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, errors.New("invalid id format"))
return
}
var req updateInventoryRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
claims, ok := middleware.GetClaims(r.Context())
if !ok || claims.CompanyID == nil {
writeError(w, http.StatusUnauthorized, errors.New("unauthorized"))
return
}
priceCents := int64(req.SalePrice * 100)
err = h.svc.UpdateInventoryItem(r.Context(), itemID, *claims.CompanyID, priceCents, req.StockQuantity)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// CreateInventoryItem godoc // CreateInventoryItem godoc
// @Summary Adicionar item ao estoque (venda) // @Summary Adicionar item ao estoque (venda)
// @Tags Estoque // @Tags Estoque
@ -479,11 +524,8 @@ func (h *Handler) CreateInventoryItem(w http.ResponseWriter, r *http.Request) {
} }
} }
// Logic: Use FinalPriceCents if provided, else SalePriceCents // Logic: Use SalePriceCents
finalPrice := req.SalePriceCents finalPrice := req.SalePriceCents
if req.FinalPriceCents > 0 {
finalPrice = req.FinalPriceCents
}
item := &domain.InventoryItem{ item := &domain.InventoryItem{
ProductID: prodID, ProductID: prodID,

View file

@ -45,12 +45,23 @@ func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
} }
} }
// Only Superadmin can create another Superadmin
if req.Superadmin {
if !strings.EqualFold(requester.Role, "super_admin") && !strings.EqualFold(requester.Role, "superadmin") { // Allow both variations just in case
writeError(w, http.StatusForbidden, errors.New("only superadmins can create superadmins"))
return
}
}
user := &domain.User{ user := &domain.User{
CompanyID: req.CompanyID, CompanyID: req.CompanyID,
Role: req.Role, Role: req.Role,
Name: req.Name, Name: req.Name,
Username: req.Username, Username: req.Username,
Email: req.Email, Email: req.Email,
Superadmin: req.Superadmin,
NomeSocial: req.NomeSocial,
CPF: req.CPF,
} }
if err := h.svc.CreateUser(r.Context(), user, req.Password); err != nil { if err := h.svc.CreateUser(r.Context(), user, req.Password); err != nil {

View file

@ -0,0 +1,3 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS superadmin BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS nome_social TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS cpf TEXT;

View file

@ -854,8 +854,8 @@ func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error {
user.CreatedAt = now user.CreatedAt = now
user.UpdatedAt = now user.UpdatedAt = now
query := `INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at) query := `INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, superadmin, nome_social, cpf, created_at, updated_at)
VALUES (:id, :company_id, :role, :name, :username, :email, :email_verified, :password_hash, :created_at, :updated_at)` VALUES (:id, :company_id, :role, :name, :username, :email, :email_verified, :password_hash, :superadmin, :nome_social, :cpf, :created_at, :updated_at)`
_, err := r.db.NamedExecContext(ctx, query, user) _, err := r.db.NamedExecContext(ctx, query, user)
return err return err
@ -1383,97 +1383,9 @@ func (r *Repository) CreateInventoryItem(ctx context.Context, item *domain.Inven
return err return err
} }
// ReplaceCart clears the cart and adds new items in a transaction. func (r *Repository) UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) error {
func (r *Repository) ReplaceCart(ctx context.Context, buyerID uuid.UUID, items []domain.CartItem) error { query := `UPDATE inventory_items SET sale_price_cents = :sale_price_cents, stock_quantity = :stock_quantity, updated_at = :updated_at WHERE id = :id AND seller_id = :seller_id`
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// 1. Clear Cart
if _, err := tx.ExecContext(ctx, "DELETE FROM cart_items WHERE buyer_id = $1", buyerID); err != nil {
return err
}
// 2. Add Items
query := `INSERT INTO cart_items (id, buyer_id, product_id, quantity, unit_cents, batch, expires_at, created_at, updated_at)
VALUES (:id, :buyer_id, :product_id, :quantity, :unit_cents, :batch, :expires_at, :created_at, :updated_at)`
for _, item := range items {
// Ensure IDs
if item.ID == uuid.Nil {
item.ID = uuid.Must(uuid.NewV7())
}
item.CreatedAt = time.Now().UTC()
item.UpdatedAt = time.Now().UTC() item.UpdatedAt = time.Now().UTC()
_, err := r.db.NamedExecContext(ctx, query, item)
if _, err := tx.NamedExecContext(ctx, query, item); err != nil {
return err return err
}
}
return tx.Commit()
}
// UpdateOrderItems replaces order items and updates total, handling stock accordingly.
func (r *Repository) UpdateOrderItems(ctx context.Context, orderID uuid.UUID, items []domain.OrderItem, totalCents int64) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// 1. Fetch existing items to restore stock
var oldItems []domain.OrderItem
if err := tx.SelectContext(ctx, &oldItems, "SELECT product_id, quantity FROM order_items WHERE order_id = $1", orderID); err != nil {
return err
}
// 2. Restore stock for old items
for _, item := range oldItems {
if _, err := tx.ExecContext(ctx, "UPDATE products SET stock = stock + $1, updated_at = $2 WHERE id = $3", item.Quantity, time.Now().UTC(), item.ProductID); err != nil {
return err
}
}
// 3. Delete existing items
if _, err := tx.ExecContext(ctx, "DELETE FROM order_items WHERE order_id = $1", orderID); err != nil {
return err
}
// 4. Insert new items and consume stock
itemQuery := `INSERT INTO order_items (id, order_id, product_id, quantity, unit_cents, batch, expires_at)
VALUES (:id, :order_id, :product_id, :quantity, :unit_cents, :batch, :expires_at)`
now := time.Now().UTC()
for _, item := range items {
if item.ID == uuid.Nil {
item.ID = uuid.Must(uuid.NewV7())
}
item.OrderID = orderID
if _, err := tx.NamedExecContext(ctx, itemQuery, item); err != nil {
return err
}
// Reduce stock
res, err := tx.ExecContext(ctx, `UPDATE products SET stock = stock - $1, updated_at = $2 WHERE id = $3 AND stock >= $1`, item.Quantity, now, item.ProductID)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("insufficient stock for product %s", item.ProductID)
}
}
// 5. Update Order Total
if _, err := tx.ExecContext(ctx, "UPDATE orders SET total_cents = $1, updated_at = $2 WHERE id = $3", totalCents, now, orderID); err != nil {
return err
}
return tx.Commit()
} }

View file

@ -109,8 +109,8 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("GET /api/v1/inventory", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/v1/inventory", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/inventory", chain(http.HandlerFunc(h.CreateInventoryItem), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/v1/inventory", chain(http.HandlerFunc(h.CreateInventoryItem), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/produtos-venda", chain(http.HandlerFunc(h.CreateInventoryItem), middleware.Logger, middleware.Gzip, auth)) // Alias mux.Handle("POST /api/v1/produtos-venda", chain(http.HandlerFunc(h.CreateInventoryItem), middleware.Logger, middleware.Gzip, auth)) // Alias
mux.Handle("PUT /api/v1/produtos-venda/{id}", chain(http.HandlerFunc(h.UpdateInventoryItem), middleware.Logger, middleware.Gzip, auth)) // Correct Handler
mux.Handle("GET /api/v1/produtos-venda", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth)) // Alias for list mux.Handle("GET /api/v1/produtos-venda", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth)) // Alias for list
mux.Handle("PUT /api/v1/produtos-venda/{id}", chain(http.HandlerFunc(h.UpdateInventoryItem), middleware.Logger, middleware.Gzip, auth)) // Update inventory
mux.Handle("POST /api/v1/inventory/adjust", chain(http.HandlerFunc(h.AdjustInventory), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/v1/inventory/adjust", chain(http.HandlerFunc(h.AdjustInventory), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/orders", chain(http.HandlerFunc(h.CreateOrder), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/v1/orders", chain(http.HandlerFunc(h.CreateOrder), middleware.Logger, middleware.Gzip, auth))

View file

@ -129,10 +129,14 @@ func (s *Service) RegisterInventoryItem(ctx context.Context, item *domain.Invent
return s.repo.CreateInventoryItem(ctx, item) return s.repo.CreateInventoryItem(ctx, item)
} }
func (s *Service) GetInventoryItem(ctx context.Context, id uuid.UUID) (*domain.InventoryItem, error) { func (s *Service) UpdateInventoryItem(ctx context.Context, itemID uuid.UUID, sellerID uuid.UUID, priceCents int64, stockQuantity int64) error {
return s.repo.GetInventoryItem(ctx, id) // We construct a partial item just for update
} item := &domain.InventoryItem{
ID: itemID,
func (s *Service) UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) error { SellerID: sellerID,
SalePriceCents: priceCents,
StockQuantity: stockQuantity,
}
// Future improvement: check if item exists and belongs to seller first, strict validation
return s.repo.UpdateInventoryItem(ctx, item) return s.repo.UpdateInventoryItem(ctx, item)
} }

View file

@ -36,7 +36,6 @@ type Repository interface {
AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error)
ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error)
CreateInventoryItem(ctx context.Context, item *domain.InventoryItem) error CreateInventoryItem(ctx context.Context, item *domain.InventoryItem) error
GetInventoryItem(ctx context.Context, id uuid.UUID) (*domain.InventoryItem, error)
UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) error UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) error
CreateOrder(ctx context.Context, order *domain.Order) error CreateOrder(ctx context.Context, order *domain.Order) error

View file

@ -183,6 +183,9 @@ func (m *MockRepository) AdjustInventory(ctx context.Context, productID uuid.UUI
func (m *MockRepository) CreateInventoryItem(ctx context.Context, item *domain.InventoryItem) error { func (m *MockRepository) CreateInventoryItem(ctx context.Context, item *domain.InventoryItem) error {
return nil return nil
} }
func (m *MockRepository) UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) error {
return nil
}
func (m *MockRepository) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error) { func (m *MockRepository) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error) {
return nil, 0, nil return nil, 0, nil
} }

View file

@ -1,12 +1,13 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { X, AlertCircle, CheckCircle, Loader } from "lucide-react"; import { X, AlertCircle, CheckCircle, Loader, Eye, EyeOff } from "lucide-react";
interface CadastroSuperadminModalProps { interface CadastroSuperadminModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSuccess?: () => void; onSuccess?: () => void;
currentCompanyId?: string;
} }
// Funções de máscara // Funções de máscara
@ -31,11 +32,15 @@ export default function CadastroSuperadminModal({
isOpen, isOpen,
onClose, onClose,
onSuccess, onSuccess,
currentCompanyId,
}: CadastroSuperadminModalProps) { }: CadastroSuperadminModalProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
nome: "", nome: "",
email: "", email: "",
@ -117,24 +122,22 @@ export default function CadastroSuperadminModal({
const telefoneLimpo = formData.telefone.replace(/\D/g, ""); const telefoneLimpo = formData.telefone.replace(/\D/g, "");
const payload = { const payload = {
identificador: formData.email, name: formData.nome,
nome: formData.nome, username: formData.email,
email: formData.email, email: formData.email,
telefone: telefoneLimpo || null, password: formData.senha,
senha: formData.senha,
cpf: cpfLimpo, cpf: cpfLimpo,
ativo: true,
superadmin: true, superadmin: true,
nivel: "admin", role: "superadmin",
"registro-completo": true,
"nome-social": formData.nomeSocial || null, "nome-social": formData.nomeSocial || null,
company_id: currentCompanyId || "00000000-0000-0000-0000-000000000000", // Fallback if no company provided, though might fail if foreign key invalid
}; };
// Obter token do localStorage para enviar autorização // Obter token do localStorage para enviar autorização
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
const response = await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_BFF_API_URL}/usuarios`, `${process.env.NEXT_PUBLIC_BFF_API_URL}/users`,
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -168,6 +171,7 @@ export default function CadastroSuperadminModal({
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
setError( setError(
errorData.message || errorData.message ||
errorData.error ||
`Erro ao criar superadmin (${response.status})` `Erro ao criar superadmin (${response.status})`
); );
} }
@ -308,15 +312,28 @@ export default function CadastroSuperadminModal({
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Senha * Senha *
</label> </label>
<div className="relative">
<input <input
type="password" type={showPassword ? "text" : "password"}
name="senha" name="senha"
value={formData.senha} value={formData.senha}
onChange={handleChange} onChange={handleChange}
placeholder="Digite a senha" placeholder="Digite a senha"
disabled={loading} disabled={loading}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 pr-10"
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none"
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div> </div>
{/* Confirmar Senha */} {/* Confirmar Senha */}
@ -324,15 +341,28 @@ export default function CadastroSuperadminModal({
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Confirmar Senha * Confirmar Senha *
</label> </label>
<div className="relative">
<input <input
type="password" type={showConfirmPassword ? "text" : "password"}
name="confirmaSenha" name="confirmaSenha"
value={formData.confirmaSenha} value={formData.confirmaSenha}
onChange={handleChange} onChange={handleChange}
placeholder="Confirme a senha" placeholder="Confirme a senha"
disabled={loading} disabled={loading}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 pr-10"
/> />
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none"
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div> </div>
{/* Botões */} {/* Botões */}

View file

@ -825,6 +825,7 @@ const Dashboard = () => {
onSuccess={() => { onSuccess={() => {
setShowCadastroSuperadminModal(false); setShowCadastroSuperadminModal(false);
}} }}
currentCompanyId={userData?.company_id || empresaId}
/> />
</div> </div>
); );

View file

@ -4,14 +4,29 @@ import { useEffect, useState } from "react";
import { EmpresaBff, empresaApiService } from "@/services/empresaApiService"; import { EmpresaBff, empresaApiService } from "@/services/empresaApiService";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { CheckCircleIcon, XCircleIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline"; import {
CheckCircleIcon,
XCircleIcon,
BuildingOfficeIcon,
MagnifyingGlassIcon,
ArrowPathIcon,
UserGroupIcon,
CalendarIcon,
EnvelopeIcon,
PhoneIcon,
IdentificationIcon,
ClockIcon
} from "@heroicons/react/24/outline";
export default function UsuariosPendentesPage() { export default function UsuariosPendentesPage() {
const router = useRouter(); const router = useRouter();
const [empresas, setEmpresas] = useState<EmpresaBff[]>([]); const [empresas, setEmpresas] = useState<EmpresaBff[]>([]);
const [filteredEmpresas, setFilteredEmpresas] = useState<EmpresaBff[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [userData, setUserData] = useState<any>(null); const [userData, setUserData] = useState<any>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [approvingId, setApprovingId] = useState<string | null>(null);
const fetchUserData = async () => { const fetchUserData = async () => {
try { try {
@ -48,6 +63,7 @@ export default function UsuariosPendentesPage() {
try { try {
const data = await empresaApiService.listar({ is_verified: false }); const data = await empresaApiService.listar({ is_verified: false });
setEmpresas(data); setEmpresas(data);
setFilteredEmpresas(data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError("Erro ao carregar usuários pendentes."); setError("Erro ao carregar usuários pendentes.");
@ -61,26 +77,51 @@ export default function UsuariosPendentesPage() {
fetchPendentes(); fetchPendentes();
}, []); }, []);
useEffect(() => {
if (!search.trim()) {
setFilteredEmpresas(empresas);
return;
}
const term = search.toLowerCase();
const filtered = empresas.filter(e =>
(e.razao_social || "").toLowerCase().includes(term) ||
(e.nome_fantasia || "").toLowerCase().includes(term) ||
(e.cnpj || "").includes(term) ||
(e.email || "").toLowerCase().includes(term)
);
setFilteredEmpresas(filtered);
}, [search, empresas]);
const handleAprovar = async (companyId: string) => { const handleAprovar = async (companyId: string) => {
if (!confirm("Tem certeza que deseja aprovar este cadastro?")) return; if (!confirm("Tem certeza que deseja aprovar este cadastro?")) return;
setApprovingId(companyId);
try { try {
const sucesso = await empresaApiService.atualizar(companyId, { const sucesso = await empresaApiService.atualizar(companyId, {
is_verified: true, is_verified: true,
} as any); } as any);
if (sucesso) { if (sucesso) {
// Remove from list without full reload for better UX
setEmpresas(prev => prev.filter(e => e.id !== companyId));
alert("Cadastro aprovado com sucesso!"); alert("Cadastro aprovado com sucesso!");
fetchPendentes();
} else { } else {
alert("Erro ao aprovar cadastro."); alert("Erro ao aprovar cadastro.");
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert("Erro ao processar aprovação."); alert("Erro ao processar aprovação.");
} finally {
setApprovingId(null);
} }
}; };
const formatarData = (dataString?: string) => {
if (!dataString) return "N/A";
return new Date(dataString).toLocaleDateString('pt-BR');
};
if (!userData && loading) { if (!userData && loading) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 flex items-center justify-center">
@ -103,6 +144,38 @@ export default function UsuariosPendentesPage() {
<main className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8"> <main className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8">
{/* Filtros e Busca */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<div className="flex flex-col sm:flex-row gap-4 items-end">
<div className="flex-1 w-full">
<label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-2">
Buscar Solicitações
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
id="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="Razão Social, CNPJ ou Email..."
/>
</div>
</div>
<button
onClick={fetchPendentes}
disabled={loading}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<ArrowPathIcon className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Atualizar
</button>
</div>
</div>
{error && ( {error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6"> <div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div className="flex"> <div className="flex">
@ -114,59 +187,158 @@ export default function UsuariosPendentesPage() {
</div> </div>
)} )}
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<ClockIcon className="h-6 w-6 text-yellow-500" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Pendentes</dt>
<dd className="text-lg font-medium text-gray-900">{empresas.length}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<CalendarIcon className="h-6 w-6 text-blue-500" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Data Hoje</dt>
<dd className="text-lg font-medium text-gray-900">{new Date().toLocaleDateString('pt-BR')}</dd>
</dl>
</div>
</div>
</div>
</div>
{/* Placeholder for more stats if needed */}
</div>
{/* Lista */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Solicitações ({filteredEmpresas.length})
</h3>
{loading ? ( {loading ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div> <div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-500">Carregando solicitações...</p> <p className="text-gray-500">Carregando solicitações...</p>
</div> </div>
) : empresas.length === 0 ? ( ) : filteredEmpresas.length === 0 ? (
<div className="bg-white shadow rounded-lg p-12 text-center"> <div className="text-center py-12">
<BuildingOfficeIcon className="mx-auto h-12 w-12 text-gray-400" /> <BuildingOfficeIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-semibold text-gray-900">Nenhum cadastro pendente</h3> <h3 className="mt-2 text-sm font-semibold text-gray-900">Nenhum cadastro encontrado</h3>
<p className="mt-1 text-sm text-gray-500">Todas as solicitações foram processadas.</p> <p className="mt-1 text-sm text-gray-500">
{search ? "Nenhum resultado para sua busca." : "Todas as solicitações foram processadas."}
</p>
</div> </div>
) : ( ) : (
<div className="grid gap-6"> <div className="space-y-4">
{empresas.map((empresa) => ( {filteredEmpresas.map((empresa) => (
<div <div
key={empresa.id} key={empresa.id}
className="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-200 hover:shadow-md transition-shadow" className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow"
> >
<div className="px-4 py-5 sm:p-6"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"> <div className="flex-1 space-y-3">
<div className="flex-1"> <div className="flex items-start justify-between">
<div className="flex items-center gap-2 mb-2"> <div>
<h3 className="text-lg font-medium leading-6 text-gray-900"> <div className="flex items-center gap-2">
<h4 className="text-lg font-medium text-gray-900">
{empresa.razao_social || empresa.nome_fantasia || "Sem Nome"} {empresa.razao_social || empresa.nome_fantasia || "Sem Nome"}
</h3> </h4>
<span className="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-inset ring-yellow-600/20"> <span className="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-inset ring-yellow-600/20">
Pendente Pendente
</span> </span>
</div> </div>
<p className="text-sm text-gray-500 flex items-center gap-1 mt-1">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 text-sm text-gray-500"> <span className="font-mono text-xs text-gray-400">ID: {empresa.id}</span>
<p><span className="font-medium text-gray-700">CNPJ:</span> {empresa.cnpj}</p> </p>
<p><span className="font-medium text-gray-700">Email:</span> {empresa.email}</p>
<p><span className="font-medium text-gray-700">Telefone:</span> {empresa.telefone}</p>
<p><span className="font-medium text-gray-700">ID:</span> <span className="font-mono text-xs">{empresa.id}</span></p>
</div>
</div> </div>
<div className="flex items-center gap-3 w-full md:w-auto mt-4 md:mt-0"> <div className="flex lg:hidden">
<button <button
onClick={() => handleAprovar(empresa.id)} onClick={() => handleAprovar(empresa.id)}
className="inline-flex flex-1 md:flex-none items-center justify-center rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600 transition-colors" disabled={approvingId === empresa.id}
className="inline-flex items-center justify-center rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 disabled:opacity-50"
> >
<CheckCircleIcon className="-ml-0.5 mr-1.5 h-5 w-5" aria-hidden="true" /> {approvingId === empresa.id ? (
Aprovar Acesso <ArrowPathIcon className="h-5 w-5 animate-spin" />
) : (
<>
<CheckCircleIcon className="-ml-0.5 mr-1.5 h-5 w-5" />
Aprovar
</>
)}
</button> </button>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 pt-2">
<div className="flex items-center gap-2">
<IdentificationIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs text-gray-500 uppercase font-medium">CNPJ</p>
<p className="text-sm font-medium text-gray-900">{empresa.cnpj}</p>
</div>
</div>
<div className="flex items-center gap-2">
<EnvelopeIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs text-gray-500 uppercase font-medium">Email</p>
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]" title={empresa.email}>{empresa.email}</p>
</div>
</div>
<div className="flex items-center gap-2">
<PhoneIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs text-gray-500 uppercase font-medium">Telefone</p>
<p className="text-sm font-medium text-gray-900">{empresa.telefone || "N/A"}</p>
</div>
</div>
<div className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs text-gray-500 uppercase font-medium">Data Cadastro</p>
<p className="text-sm font-medium text-gray-900">{formatarData(empresa.created_at)}</p>
</div>
</div>
</div>
</div>
<div className="hidden lg:flex items-center gap-3">
<button
onClick={() => handleAprovar(empresa.id)}
disabled={approvingId === empresa.id}
className="inline-flex items-center justify-center rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 disabled:opacity-50 transition-colors"
>
{approvingId === empresa.id ? (
<ArrowPathIcon className="h-5 w-5 animate-spin" />
) : (
<>
<CheckCircleIcon className="-ml-0.5 mr-1.5 h-5 w-5" />
Aprovar Acesso
</>
)}
</button>
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div>
</div>
</main> </main>
</div> </div>
); );

View file

@ -21,6 +21,8 @@ export interface EmpresaBff {
estado: string; estado: string;
latitude?: string; latitude?: string;
longitude?: string; longitude?: string;
created_at?: string;
category?: string;
} }
export const empresaApiService = { export const empresaApiService = {
@ -97,6 +99,9 @@ export const empresaApiService = {
...item, ...item,
razao_social: item.corporate_name || item.razao_social, // Fallback razao_social: item.corporate_name || item.razao_social, // Fallback
nome_fantasia: item.trade_name || item.nome_fantasia || "", nome_fantasia: item.trade_name || item.nome_fantasia || "",
telefone: item.phone || item.telefone || "N/A",
created_at: item.created_at,
category: item.category,
})); }));
} catch (error) { } catch (error) {
console.error('❌ Erro ao listar empresas:', error); console.error('❌ Erro ao listar empresas:', error);