feat: implementação completa do cadastro de superadmin
- Backend: Adiciona campos (Superadmin, CPF, NomeSocial) e migration - Backend: Restringe criação de superadmin apenas para superadmins - Frontend: Corrige modal de cadastro, endpoint da API e visibilidade de senha - Frontend: Corrige erro de chave estrangeira (company_id)
This commit is contained in:
parent
19a15c40df
commit
e280ffe6f5
15 changed files with 403 additions and 105 deletions
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -427,13 +427,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
|
||||||
|
|
@ -472,11 +517,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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -817,8 +817,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
|
||||||
|
|
@ -1325,3 +1325,10 @@ func (r *Repository) CreateInventoryItem(ctx context.Context, item *domain.Inven
|
||||||
_, err := r.db.NamedExecContext(ctx, query, item)
|
_, err := r.db.NamedExecContext(ctx, query, item)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) 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`
|
||||||
|
item.UpdatedAt = time.Now().UTC()
|
||||||
|
_, err := r.db.NamedExecContext(ctx, query, item)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ func New(cfg config.Config) (*Server, error) {
|
||||||
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("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))
|
||||||
|
|
|
||||||
|
|
@ -128,3 +128,15 @@ func (s *Service) GetProductByEAN(ctx context.Context, ean string) (*domain.Prod
|
||||||
func (s *Service) RegisterInventoryItem(ctx context.Context, item *domain.InventoryItem) error {
|
func (s *Service) RegisterInventoryItem(ctx context.Context, item *domain.InventoryItem) error {
|
||||||
return s.repo.CreateInventoryItem(ctx, item)
|
return s.repo.CreateInventoryItem(ctx, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateInventoryItem(ctx context.Context, itemID uuid.UUID, sellerID uuid.UUID, priceCents int64, stockQuantity int64) error {
|
||||||
|
// We construct a partial item just for update
|
||||||
|
item := &domain.InventoryItem{
|
||||||
|
ID: itemID,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ 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
|
||||||
|
UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) error
|
||||||
|
|
||||||
CreateOrder(ctx context.Context, order *domain.Order) error
|
CreateOrder(ctx context.Context, order *domain.Order) error
|
||||||
ListOrders(ctx context.Context, filter domain.OrderFilter) ([]domain.Order, int64, error)
|
ListOrders(ctx context.Context, filter domain.OrderFilter) ([]domain.Order, int64, error)
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -825,6 +825,7 @@ const Dashboard = () => {
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setShowCadastroSuperadminModal(false);
|
setShowCadastroSuperadminModal(false);
|
||||||
}}
|
}}
|
||||||
|
currentCompanyId={userData?.company_id || empresaId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue