Merge pull request #64 from rede5/task1

Task1
This commit is contained in:
Andre F. Rodrigues 2026-01-21 17:21:09 -03:00 committed by GitHub
commit d85e436de2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 533 additions and 673 deletions

View file

@ -7,20 +7,34 @@ APP_NAME=saveinmed-performance-core
BACKEND_PORT=8214
# Database Configuration
DATABASE_URL=postgres://yuki:xl1zfmr6e9bb@db-60059.dc-sp-1.absamcloud.com:26868/sim_dev?sslmode=disable
DATABASE_URL=postgres://postgres:123@localhost:55432/saveinmed?sslmode=disable
ADMIN_NAME=Admin Master
ADMIN_USERNAME=admin
ADMIN_EMAIL=andre.fr93@gmail.com
ADMIN_PASSWORD=teste1234
# JWT Authentication
JWT_SECRET=your-secret-key-here
JWT_EXPIRES_IN=24h
PASSWORD_PEPPER=your-password-pepper
# MercadoPago Payment Gateway
MERCADOPAGO_BASE_URL=https://api.mercadopago.com
MARKETPLACE_COMMISSION=2.5
# CORS Configuration (comma-separated list of allowed origins, use * for all)
CORS_ORIGINS=*
# CORS Configuration
# Comma-separated list of allowed origins, use * for all
# Examples:
# CORS_ORIGINS=*
# CORS_ORIGINS=https://example.com
# CORS_ORIGINS=https://app.saveinmed.com,https://admin.saveinmed.com,http://localhost:3000
CORS_ORIGINS=http://localhost:3000
ADMIN_NAME=Administrator
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@saveinmed.com
ADMIN_PASSWORD=admin123
# Swagger Configuration
# Host without scheme (ex: localhost:8214 or api.saveinmed.com)
BACKEND_HOST=localhost:8214
# Comma-separated list of schemes shown in Swagger UI selector
SWAGGER_SCHEMES=http,https
# Testing (Optional)
# SKIP_DB_TEST=1

View file

@ -143,6 +143,7 @@ type CompanyFilter struct {
Search string
City string
State string
IsVerified *bool
Limit int
Offset int
}
@ -321,6 +322,22 @@ type PaymentGatewayConfig struct {
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Address represents a physical location for users or companies.
type Address struct {
ID uuid.UUID `db:"id" json:"id"`
EntityID uuid.UUID `db:"entity_id" json:"entity_id"`
Title string `db:"title" json:"titulo"`
ZipCode string `db:"zip_code" json:"cep"`
Street string `db:"street" json:"logradouro"`
Number string `db:"number" json:"numero"`
Complement string `db:"complement" json:"complemento"`
District string `db:"district" json:"bairro"`
City string `db:"city" json:"cidade"`
State string `db:"state" json:"uf"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ShippingAddress captures delivery details at order time.
type ShippingAddress struct {
RecipientName string `json:"recipient_name" db:"shipping_recipient_name"`

View file

@ -0,0 +1,48 @@
package handler
import (
"log"
"net/http"
"time"
"github.com/saveinmed/backend-go/internal/domain"
)
func (h *Handler) CreateAddress(w http.ResponseWriter, r *http.Request) {
reqUser, err := getRequester(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var payload createAddressRequest
if err := decodeJSON(r.Context(), r, &payload); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
// Use user ID as the entity ID
entityID := reqUser.ID
addr := domain.Address{
EntityID: entityID,
Title: payload.Title,
ZipCode: payload.ZipCode,
Street: payload.Street,
Number: payload.Number,
Complement: payload.Complement,
District: payload.District,
City: payload.City,
State: payload.State,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.svc.CreateAddress(r.Context(), &addr); err != nil {
log.Printf("Failed to create address: %v", err)
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, addr)
}

View file

@ -3,6 +3,7 @@ package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/saveinmed/backend-go/internal/domain"
@ -25,15 +26,30 @@ func (h *Handler) CreateCompany(w http.ResponseWriter, r *http.Request) {
return
}
// Map Portuguese fields if English ones are empty
if req.CorporateName == "" {
req.CorporateName = req.RazaoSocial
}
if req.Category == "" {
// Default category if not provided or map from Activity Code?
// For now, use description or default
if req.DescricaoAtividade != "" {
req.Category = req.DescricaoAtividade
} else {
req.Category = "farmacia" // Default
}
}
company := &domain.Company{
Category: req.Category,
CNPJ: req.CNPJ,
CorporateName: req.CorporateName,
LicenseNumber: req.LicenseNumber,
LicenseNumber: req.LicenseNumber, // Frontend might not send this yet?
Latitude: req.Latitude,
Longitude: req.Longitude,
City: req.City,
State: req.State,
Phone: req.Telefone,
}
if err := h.svc.RegisterCompany(r.Context(), company); err != nil {
@ -59,6 +75,12 @@ func (h *Handler) ListCompanies(w http.ResponseWriter, r *http.Request) {
State: r.URL.Query().Get("state"),
}
if v := r.URL.Query().Get("is_verified"); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
filter.IsVerified = &b
}
}
result, err := h.svc.ListCompanies(r.Context(), filter, page, pageSize)
if err != nil {
writeError(w, http.StatusInternalServerError, err)

View file

@ -66,7 +66,7 @@ type verifyEmailRequest struct {
}
type authResponse struct {
Token string `json:"token"`
Token string `json:"access_token"`
ExpiresAt time.Time `json:"expires_at"`
}
@ -104,9 +104,24 @@ type updateUserRequest struct {
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
EmpresasDados []string `json:"empresasDados"` // Frontend sends array of strings
Enderecos []string `json:"enderecos"` // Frontend sends array of strings
// Ignored fields sent by frontend to prevent "unknown field" errors
ID interface{} `json:"id,omitempty"`
EmailVerified interface{} `json:"email_verified,omitempty"`
CreatedAt interface{} `json:"created_at,omitempty"`
UpdatedAt interface{} `json:"updated_at,omitempty"`
Nome interface{} `json:"nome,omitempty"`
Ativo interface{} `json:"ativo,omitempty"`
CPF interface{} `json:"cpf,omitempty"`
NomeSocial interface{} `json:"nome-social,omitempty"`
RegistroCompleto interface{} `json:"registro-completo,omitempty"`
Nivel interface{} `json:"nivel,omitempty"`
}
type requester struct {
ID uuid.UUID
Role string
CompanyID *uuid.UUID
}
@ -120,6 +135,27 @@ type registerCompanyRequest struct {
Longitude float64 `json:"longitude"`
City string `json:"city"`
State string `json:"state"`
// Portuguese Frontend Compatibility
RazaoSocial string `json:"razao-social"`
NomeFantasia string `json:"nome-fantasia"`
DataAbertura string `json:"data-abertura"` // Fixed: frontend sends hyphen
Telefone string `json:"telefone"`
CodigoAtividade string `json:"codigo_atividade"`
DescricaoAtividade string `json:"descricao_atividade"`
Situacao string `json:"situacao"` // Ignored for now
NaturezaJuridica string `json:"natureza-juridica"` // Ignored for now
Porte string `json:"porte"` // Ignored for now
AtividadePrincipal string `json:"atividade-principal"` // Frontend might send this
AtividadePrincipalCodigo string `json:"atividade-principal-codigo"` // Frontend sends this
AtividadePrincipalDesc string `json:"atividade-principal-desc"` // Frontend sends this
Email string `json:"email"` // Frontend sends this
CapitalSocial float64 `json:"capital-social"` // Frontend sends this (number)
AddressID string `json:"enderecoID"` // Frontend sends this
TipoFrete string `json:"tipoFrete"` // Frontend sends this
RaioEntregaKm float64 `json:"raioEntregaKm"` // Frontend sends this
TaxaEntrega float64 `json:"taxaEntrega"` // Frontend sends this
ValorFreteKm float64 `json:"valorFreteKm"` // Frontend sends this
}
type updateCompanyRequest struct {
@ -195,12 +231,24 @@ type shippingCalculateRequest struct {
PostalCode string `json:"postal_code,omitempty"`
}
type createAddressRequest struct {
Title string `json:"titulo"`
ZipCode string `json:"cep"`
Street string `json:"logradouro"`
Number string `json:"numero"`
Complement string `json:"complemento"`
District string `json:"bairro"`
City string `json:"cidade"`
State string `json:"estado"` // JSON from frontend sends "estado"
Country string `json:"pais"` // JSON includes "pais"
}
// --- Utility Functions ---
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
_ = jsonAPI.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, err error) {
@ -211,7 +259,7 @@ func decodeJSON(ctx context.Context, r *http.Request, v any) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
dec := json.NewDecoder(r.Body)
dec := jsonAPI.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
@ -276,7 +324,7 @@ func parsePagination(r *http.Request) (int, int) {
func getRequester(r *http.Request) (requester, error) {
if claims, ok := middleware.GetClaims(r.Context()); ok {
return requester{Role: claims.Role, CompanyID: claims.CompanyID}, nil
return requester{ID: claims.UserID, Role: claims.Role, CompanyID: claims.CompanyID}, nil
}
role := r.Header.Get("X-User-Role")
if role == "" {

View file

@ -17,7 +17,7 @@ func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) {
Type string `json:"type"`
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := jsonAPI.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
@ -29,7 +29,7 @@ func (h *Handler) UploadDocument(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(doc)
jsonAPI.NewEncoder(w).Encode(doc)
}
// GetDocuments lists company KYC docs.
@ -47,7 +47,7 @@ func (h *Handler) GetDocuments(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(docs)
jsonAPI.NewEncoder(w).Encode(docs)
}
// GetLedger returns financial history.
@ -68,7 +68,7 @@ func (h *Handler) GetLedger(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
jsonAPI.NewEncoder(w).Encode(res)
}
// GetBalance returns current wallet balance.
@ -86,7 +86,7 @@ func (h *Handler) GetBalance(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int64{"balance_cents": bal})
jsonAPI.NewEncoder(w).Encode(map[string]int64{"balance_cents": bal})
}
// RequestWithdrawal initiates a payout.
@ -101,7 +101,7 @@ func (h *Handler) RequestWithdrawal(w http.ResponseWriter, r *http.Request) {
AmountCents int64 `json:"amount_cents"`
BankInfo string `json:"bank_info"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := jsonAPI.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
@ -113,7 +113,7 @@ func (h *Handler) RequestWithdrawal(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(wd)
jsonAPI.NewEncoder(w).Encode(wd)
}
// ListWithdrawals shows history of payouts.
@ -131,5 +131,5 @@ func (h *Handler) ListWithdrawals(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(wds)
jsonAPI.NewEncoder(w).Encode(wds)
}

View file

@ -5,6 +5,8 @@ import (
"database/sql"
"errors"
"net/http"
"strconv"
"time"
jsoniter "github.com/json-iterator/go"
@ -15,7 +17,7 @@ import (
"github.com/saveinmed/backend-go/internal/usecase"
)
var json = jsoniter.ConfigCompatibleWithStandardLibrary
var jsonAPI = jsoniter.ConfigCompatibleWithStandardLibrary
type Handler struct {
svc *usecase.Service
@ -72,9 +74,19 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
Email: req.Email,
}
// If no company provided, create a placeholder one to satisfy DB constraints
if user.CompanyID == uuid.Nil && company == nil {
writeError(w, http.StatusBadRequest, errors.New("company_id or company payload is required"))
return
timestamp := time.Now().UnixNano()
company = &domain.Company{
// ID left as Nil so usecase creates it
Category: "farmacia",
CNPJ: "TMP-" + strconv.FormatInt(timestamp, 10), // Temporary CNPJ
CorporateName: "Empresa de " + req.Name,
LicenseNumber: "PENDING",
IsVerified: false,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
}
if err := h.svc.RegisterAccount(r.Context(), company, user, req.Password); err != nil {
@ -123,6 +135,52 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, authResponse{Token: token, ExpiresAt: exp})
}
// GetMe godoc
// @Summary Obter dados do usuário logado
// @Tags Autenticação
// @Security BearerAuth
// @Produce json
// @Success 200 {object} domain.User
// @Failure 401 {object} map[string]string
// @Router /api/v1/auth/me [get]
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
requester, err := getRequester(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
user, err := h.svc.GetUser(r.Context(), requester.ID)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
var companyName string
var isSuperAdmin bool
if user.CompanyID != uuid.Nil {
if c, err := h.svc.GetCompany(r.Context(), user.CompanyID); err == nil && c != nil {
companyName = c.CorporateName
if c.Category == "admin" {
isSuperAdmin = true
}
}
}
response := struct {
*domain.User
CompanyName string `json:"company_name"`
SuperAdmin bool `json:"superadmin"`
}{
User: user,
CompanyName: companyName,
SuperAdmin: isSuperAdmin,
}
writeJSON(w, http.StatusOK, response)
}
// RegisterCustomer godoc
// @Summary Cadastro de cliente
// @Description Cria um usuário do tipo cliente e opcionalmente uma empresa, retornando token JWT.

View file

@ -43,6 +43,11 @@ func NewMockRepository() *MockRepository {
}
}
func (m *MockRepository) CreateAddress(ctx context.Context, address *domain.Address) error {
address.ID = uuid.Must(uuid.NewV7())
return nil
}
// Company methods
func (m *MockRepository) CreateCompany(ctx context.Context, company *domain.Company) error {
id, _ := uuid.NewV7()

View file

@ -199,6 +199,14 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
if req.CompanyID != nil {
user.CompanyID = *req.CompanyID
}
// Map frontend's array of company IDs to the single CompanyID
if len(req.EmpresasDados) > 0 {
// Use the first company ID from the list
if id, err := uuid.FromString(req.EmpresasDados[0]); err == nil {
user.CompanyID = id
}
}
if req.Role != nil {
user.Role = *req.Role
}

View file

@ -36,7 +36,8 @@ func CORSWithConfig(cfg CORSConfig) func(http.Handler) http.Handler {
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
// Handle preflight requests

View file

@ -3,7 +3,4 @@ ALTER TABLE companies ADD COLUMN phone TEXT NOT NULL DEFAULT '';
ALTER TABLE companies ADD COLUMN operating_hours TEXT NOT NULL DEFAULT '';
ALTER TABLE companies ADD COLUMN is_24_hours BOOLEAN NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE companies DROP COLUMN phone;
ALTER TABLE companies DROP COLUMN operating_hours;
ALTER TABLE companies DROP COLUMN is_24_hours;

View file

@ -6,9 +6,4 @@ ALTER TABLE products ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS subcategory TEXT NOT NULL DEFAULT '';
ALTER TABLE products ADD COLUMN IF NOT EXISTS observations TEXT NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE products DROP COLUMN IF EXISTS ean_code;
ALTER TABLE products DROP COLUMN IF EXISTS manufacturer;
ALTER TABLE products DROP COLUMN IF EXISTS category;
ALTER TABLE products DROP COLUMN IF EXISTS subcategory;
ALTER TABLE products DROP COLUMN IF EXISTS observations;

View file

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS addresses (
id UUID PRIMARY KEY,
entity_id UUID NOT NULL, -- UserID or CompanyID
title TEXT NOT NULL,
zip_code TEXT NOT NULL,
street TEXT NOT NULL,
number TEXT NOT NULL,
complement TEXT,
district TEXT NOT NULL,
city TEXT NOT NULL,
state TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_addresses_entity_id ON addresses(entity_id);

View file

@ -57,6 +57,10 @@ func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFil
clauses = append(clauses, fmt.Sprintf("state = $%d", len(args)+1))
args = append(args, filter.State)
}
if filter.IsVerified != nil {
clauses = append(clauses, fmt.Sprintf("is_verified = $%d", len(args)+1))
args = append(args, *filter.IsVerified)
}
where := ""
if len(clauses) > 0 {
@ -1294,3 +1298,11 @@ account_id = EXCLUDED.account_id, account_type = EXCLUDED.account_type, status =
_, err := r.db.NamedExecContext(ctx, query, account)
return err
}
func (r *Repository) CreateAddress(ctx context.Context, address *domain.Address) error {
query := `INSERT INTO addresses (id, entity_id, title, zip_code, street, number, complement, district, city, state, created_at, updated_at)
VALUES (:id, :entity_id, :title, :zip_code, :street, :number, :complement, :district, :city, :state, :created_at, :updated_at)`
_, err := r.db.NamedExecContext(ctx, query, address)
return err
}

View file

@ -64,10 +64,14 @@ func New(cfg config.Config) (*Server, error) {
auth := middleware.RequireAuth([]byte(cfg.JWTSecret))
adminOnly := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin")
// Companies (Empresas)
mux.Handle("POST /api/v1/companies", chain(http.HandlerFunc(h.CreateCompany), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/empresas", chain(http.HandlerFunc(h.CreateCompany), middleware.Logger, middleware.Gzip)) // Alias for frontend
mux.Handle("GET /api/v1/companies", chain(http.HandlerFunc(h.ListCompanies), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/empresas", chain(http.HandlerFunc(h.ListCompanies), middleware.Logger, middleware.Gzip)) // Alias for frontend
mux.Handle("GET /api/v1/companies/{id}", chain(http.HandlerFunc(h.GetCompany), middleware.Logger, middleware.Gzip))
mux.Handle("PATCH /api/v1/companies/{id}", chain(http.HandlerFunc(h.UpdateCompany), middleware.Logger, middleware.Gzip))
mux.Handle("PATCH /api/v1/empresas/{id}", chain(http.HandlerFunc(h.UpdateCompany), middleware.Logger, middleware.Gzip)) // Alias for frontend
mux.Handle("DELETE /api/v1/companies/{id}", chain(http.HandlerFunc(h.DeleteCompany), middleware.Logger, middleware.Gzip))
mux.Handle("PATCH /api/v1/companies/{id}/verify", chain(http.HandlerFunc(h.VerifyCompany), middleware.Logger, middleware.Gzip, adminOnly))
mux.Handle("GET /api/v1/companies/me", chain(http.HandlerFunc(h.GetMyCompany), middleware.Logger, middleware.Gzip, auth))
@ -135,13 +139,14 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("POST /api/v1/auth/register/customer", chain(http.HandlerFunc(h.RegisterCustomer), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/register/tenant", chain(http.HandlerFunc(h.RegisterTenant), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/login", chain(http.HandlerFunc(h.Login), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/auth/me", chain(http.HandlerFunc(h.GetMe), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/auth/logout", chain(http.HandlerFunc(h.Logout), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/password/forgot", chain(http.HandlerFunc(h.ForgotPassword), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/password/reset", chain(http.HandlerFunc(h.ResetPassword), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/refresh-token", chain(http.HandlerFunc(h.RefreshToken), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/verify-email", chain(http.HandlerFunc(h.VerifyEmail), middleware.Logger, middleware.Gzip))
// Push Notifications (FCM)
// Address
mux.Handle("POST /api/v1/enderecos", chain(http.HandlerFunc(h.CreateAddress), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/push/register", chain(http.HandlerFunc(h.RegisterPushToken), middleware.Logger, middleware.Gzip, auth))
mux.Handle("DELETE /api/v1/push/unregister", chain(http.HandlerFunc(h.UnregisterPushToken), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/push/test", chain(http.HandlerFunc(h.TestPushNotification), middleware.Logger, middleware.Gzip, auth))
@ -150,6 +155,8 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("GET /api/v1/users", chain(http.HandlerFunc(h.ListUsers), middleware.Logger, middleware.Gzip, auth))
mux.Handle("GET /api/v1/users/", chain(http.HandlerFunc(h.GetUser), middleware.Logger, middleware.Gzip, auth))
mux.Handle("PUT /api/v1/users/", chain(http.HandlerFunc(h.UpdateUser), middleware.Logger, middleware.Gzip, auth))
mux.Handle("PATCH /api/v1/users/", chain(http.HandlerFunc(h.UpdateUser), middleware.Logger, middleware.Gzip, auth)) // Add PATCH support
mux.Handle("PATCH /api/v1/usuarios/", chain(http.HandlerFunc(h.UpdateUser), middleware.Logger, middleware.Gzip, auth)) // Alias for frontend
mux.Handle("DELETE /api/v1/users/", chain(http.HandlerFunc(h.DeleteUser), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/cart", chain(http.HandlerFunc(h.AddToCart), middleware.Logger, middleware.Gzip, auth))
@ -186,7 +193,7 @@ func (s *Server) Start(ctx context.Context) error {
// 1. Create/Get Admin Company
adminCNPJ := "00000000000000"
company := &domain.Company{
ID: uuid.Must(uuid.NewV7()),
ID: uuid.Nil,
CNPJ: adminCNPJ,
CorporateName: "SaveInMed Admin",
Category: "admin",
@ -231,6 +238,10 @@ func (s *Server) Start(ctx context.Context) error {
// For now, let's log error but not fail startup hard, or fail hard to signal issue.
log.Printf("Failed to seed admin: %v", err)
} else {
// FORCE VERIFY the admin company
if _, err := s.svc.VerifyCompany(ctx, company.ID); err != nil {
log.Printf("Failed to verify admin company: %v", err)
}
log.Printf("Admin user created successfully")
}
} else {

View file

@ -80,6 +80,8 @@ type Repository interface {
UpsertPaymentGatewayConfig(ctx context.Context, config *domain.PaymentGatewayConfig) error
GetSellerPaymentAccount(ctx context.Context, sellerID uuid.UUID) (*domain.SellerPaymentAccount, error)
UpsertSellerPaymentAccount(ctx context.Context, account *domain.SellerPaymentAccount) error
CreateAddress(ctx context.Context, address *domain.Address) error
}
// PaymentGateway abstracts Mercado Pago integration.
@ -784,6 +786,18 @@ func (s *Service) Authenticate(ctx context.Context, identifier, password string)
return "", time.Time{}, errors.New("invalid credentials")
}
// Check if company is verified
if user.CompanyID != uuid.Nil {
company, err := s.repo.GetCompany(ctx, user.CompanyID)
if err != nil {
// If company not found, something is wrong with data integrity, prevent login
return "", time.Time{}, errors.New("associated company not found")
}
if !company.IsVerified {
return "", time.Time{}, errors.New("account pending approval")
}
}
return s.issueAccessToken(user)
}
@ -997,3 +1011,8 @@ func (s *Service) GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (
func (s *Service) UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error {
return s.repo.UpsertShippingSettings(ctx, settings)
}
func (s *Service) CreateAddress(ctx context.Context, address *domain.Address) error {
address.ID = uuid.Must(uuid.NewV7())
return s.repo.CreateAddress(ctx, address)
}

View file

@ -47,6 +47,14 @@ func NewMockRepository() *MockRepository {
}
}
// Address methods
func (m *MockRepository) CreateAddress(ctx context.Context, address *domain.Address) error {
address.ID = uuid.Must(uuid.NewV7())
address.CreatedAt = time.Now()
address.UpdatedAt = time.Now()
return nil
}
// Company methods
func (m *MockRepository) CreateCompany(ctx context.Context, company *domain.Company) error {
company.CreatedAt = time.Now()

1
backend-old/login.json Normal file
View file

@ -0,0 +1 @@
{"email":"andre.fr93@gmail.com","password":"teste1234"}

View file

@ -19,7 +19,8 @@ export async function GET(
);
}
const token = process.env.CEP_API_TOKEN;
// Token fornecido pelo usuário
const token = "9426cdf7a6f36931f5afba5a3c4e7bf29974fec6d2662ebcb6d7a1b237ffacdc";
// Fazer a requisição para a API AwesomeAPI
const response = await fetch(

View file

@ -191,6 +191,7 @@ const CompletarRegistroPage = () => {
console.log("📝 Campos pré-preenchidos com dados do usuário");
} else {
console.log(" Não foi possível carregar dados do usuário");
// Do not remove token here blindly
}
} else {
console.log(" Nenhum token encontrado, formulário será preenchido manualmente");

View file

@ -67,12 +67,16 @@ const LoginPageContent = () => {
if (response.ok) {
const userData = await response.json();
router.push("/dashboard");
// ... (log logic)
} else {
const errorText = await response.text();
// Limpar token inválido
console.log("❌ Falha no /me:", errorText);
// Only remove token if explicitly unauthorized (401)
if (response.status === 401) {
localStorage.removeItem('access_token');
}
}
} catch (error) {
} finally {
setCheckingAuth(false);
@ -102,8 +106,8 @@ const LoginPageContent = () => {
credentials: 'include', // Permite que o browser receba e armazene cookies
mode: 'cors', // Habilita CORS explicitamente
body: JSON.stringify({
identificador: email,
senha: password
email: email,
password: password
})
});
@ -236,13 +240,11 @@ const LoginPageContent = () => {
'Content-Type': 'application/json',
},
body: JSON.stringify({
identificador: email, // identificador = email do usuário (usado no login)
role: "Seller",
name: name,
username: email,
email: email,
nome: name, // nome = nome completo do usuário
senha: password,
nivel: "admin", // valor estático
superadmin: false, // valor estático
"registro-completo": false // valor estático - registro incompleto por padrão
password: password
})
});
@ -278,8 +280,8 @@ const LoginPageContent = () => {
credentials: 'include',
mode: 'cors',
body: JSON.stringify({
identificador: email,
senha: password
email: email,
password: password
})
});

View file

@ -1,649 +1,173 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import { EmpresaBff, empresaApiService } from "@/services/empresaApiService";
import { useRouter } from "next/navigation";
import Header from "@/components/Header";
import { CheckCircleIcon, XCircleIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline";
interface Usuario {
id: string;
identificador: string;
nome: string;
email: string;
telefone?: string;
cpf?: string;
ativo: boolean;
superadmin: boolean;
nivel: string;
registro_completo: boolean;
enderecos: string[];
empresas_dados: string[];
createdAt: string;
updatedAt: string;
}
interface Endereco {
id: string;
cep: string;
logradouro: string;
numero: string;
complemento?: string;
bairro: string;
cidade: string;
estado: string;
}
interface EmpresaDados {
id: string;
cnpj: string;
"razao-social": string;
"nome-fantasia": string;
"data-abertura": string;
situacao: string;
"natureza-juridica": string;
porte: string;
"capital-social": number;
telefone: string;
email: string;
"atividade-principal-codigo": string;
"atividade-principal-desc": string;
enderecos: string[];
}
interface UsuarioCompleto extends Usuario {
enderecoData?: Endereco;
empresaData?: EmpresaDados;
}
const UsuariosPendentesPage = () => {
export default function UsuariosPendentesPage() {
const router = useRouter();
const [usuarios, setUsuarios] = useState<UsuarioCompleto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaBff[]>([]);
const [loading, setLoading] = useState(true);
const [userData, setUserData] = useState<any>(null);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [selectedUser, setSelectedUser] = useState<UsuarioCompleto | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [activatingUserId, setActivatingUserId] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState<any>(null); // Usuário logado
// Carregar dados do usuário logado
useEffect(() => {
const userData = localStorage.getItem('user');
if (userData) {
const fetchUserData = async () => {
try {
setCurrentUser(JSON.parse(userData));
} catch (error) {
console.error('Erro ao parsing dos dados do usuário:', error);
}
}
}, []);
// Buscar usuários pendentes
const fetchUsuarios = async (pageNum: number = 1) => {
try {
setLoading(true);
const token = localStorage.getItem('access_token');
if (!token) {
router.push('/login');
const storedToken = localStorage.getItem('access_token');
if (!storedToken) {
router.push("/login");
return;
}
const response = await fetch(
`${process.env.NEXT_PUBLIC_BFF_API_URL}/usuarios?page=${pageNum}&ativo=false`,
{
method: 'GET',
const response = await fetch(`${process.env.NEXT_PUBLIC_BFF_API_URL}/auth/me`, {
method: "GET",
headers: {
'accept': 'application/json',
'Authorization': `Bearer ${token}`,
"accept": "application/json",
"Authorization": `Bearer ${storedToken}`,
},
}
);
});
if (!response.ok) {
throw new Error('Erro ao buscar usuários pendentes');
localStorage.removeItem('access_token');
router.push("/login");
return;
}
const data = await response.json();
const usuariosData = data.data || data.items || data;
// Para cada usuário, buscar dados do endereço e empresa
const usuariosCompletos = await Promise.all(
usuariosData.map(async (usuario: Usuario) => {
const usuarioCompleto: UsuarioCompleto = { ...usuario };
// Buscar dados do endereço
if (usuario.enderecos && usuario.enderecos.length > 0) {
try {
const enderecoResponse = await fetch(
`${process.env.NEXT_PUBLIC_BFF_API_URL}/enderecos/${usuario.enderecos[0]}`,
{
headers: {
'accept': 'application/json',
'Authorization': `Bearer ${token}`,
},
}
);
if (enderecoResponse.ok) {
usuarioCompleto.enderecoData = await enderecoResponse.json();
}
const userResponse = await response.json();
setUserData(userResponse);
} catch (error) {
console.error('Erro ao buscar endereço:', error);
}
console.error("Erro ao carregar dados do usuário:", error);
}
};
// Buscar dados da empresa
if (usuario.empresas_dados && usuario.empresas_dados.length > 0) {
const fetchPendentes = async () => {
setLoading(true);
setError("");
try {
const empresaResponse = await fetch(
`${process.env.NEXT_PUBLIC_BFF_API_URL}/empresas/${usuario.empresas_dados[0]}`,
{
headers: {
'accept': 'application/json',
'Authorization': `Bearer ${token}`,
},
}
);
if (empresaResponse.ok) {
usuarioCompleto.empresaData = await empresaResponse.json();
}
} catch (error) {
console.error('Erro ao buscar empresa:', error);
}
}
return usuarioCompleto;
})
);
setUsuarios(usuariosCompletos);
// Se há paginação na resposta
if (data.pagination) {
setTotalPages(data.pagination.totalPages || 1);
}
} catch (error: any) {
console.error('❌ Erro ao carregar usuários:', error);
setError(error.message || 'Erro ao carregar usuários pendentes');
const data = await empresaApiService.listar({ is_verified: false });
setEmpresas(data);
} catch (err) {
console.error(err);
setError("Erro ao carregar usuários pendentes.");
} finally {
setLoading(false);
}
};
// Ativar usuário
const ativarUsuario = async (usuarioId: string) => {
try {
setActivatingUserId(usuarioId);
const token = localStorage.getItem('access_token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_BFF_API_URL}/usuarios/${usuarioId}`,
{
method: 'PATCH',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
ativo: true
}),
}
);
if (!response.ok) {
throw new Error('Erro ao ativar usuário');
}
// Recarregar lista
await fetchUsuarios(page);
// Fechar modal se estiver aberto
if (showDetailsModal && selectedUser?.id === usuarioId) {
setShowDetailsModal(false);
setSelectedUser(null);
}
} catch (error: any) {
console.error('❌ Erro ao ativar usuário:', error);
setError(error.message || 'Erro ao ativar usuário');
} finally {
setActivatingUserId(null);
}
};
// Ver detalhes do usuário
const verDetalhes = (usuario: UsuarioCompleto) => {
setSelectedUser(usuario);
setShowDetailsModal(true);
};
// Fechar modal de detalhes
const closeDetailsModal = () => {
setShowDetailsModal(false);
setSelectedUser(null);
};
// Carregar usuários ao montar componente
useEffect(() => {
fetchUsuarios(page);
}, [page]);
fetchUserData();
fetchPendentes();
}, []);
const handleAprovar = async (companyId: string) => {
if (!confirm("Tem certeza que deseja aprovar este cadastro?")) return;
try {
const sucesso = await empresaApiService.atualizar(companyId, {
is_verified: true,
} as any);
if (sucesso) {
alert("Cadastro aprovado com sucesso!");
fetchPendentes();
} else {
alert("Erro ao aprovar cadastro.");
}
} catch (err) {
console.error(err);
alert("Erro ao processar aprovação.");
}
};
if (!userData && loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-gray-900 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">Carregando...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={currentUser} />
<Header
user={userData}
title="Cadastros Pendentes"
subtitle="Gerencie as solicitações de acesso à plataforma"
showBackButton
/>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Cabeçalho da página */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Usuários Pendentes</h1>
<p className="mt-2 text-gray-600">
Gerencie usuários aguardando aprovação de cadastro
</p>
</div>
<button
onClick={() => router.push('/dashboard')}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar ao Dashboard
</button>
</div>
</div>
<main className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8">
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div className="flex">
<XCircleIcon className="h-5 w-5 text-red-400" />
<div className="ml-3">
<p className="text-sm text-red-800">{error}</p>
</div>
</div>
</div>
)}
{/* Conteúdo */}
{loading ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">Carregando usuários pendentes...</p>
<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>
<p className="text-gray-500">Carregando solicitações...</p>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
) : usuarios.length === 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Nenhum usuário pendente
</h3>
<p className="text-gray-600">
Todos os usuários foram processados ou não cadastros aguardando aprovação.
</p>
) : empresas.length === 0 ? (
<div className="bg-white shadow rounded-lg p-12 text-center">
<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>
<p className="mt-1 text-sm text-gray-500">Todas as solicitações foram processadas.</p>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
{/* Tabela de usuários */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Usuário
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Empresa
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Data do Cadastro
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{usuarios.map((usuario) => (
<tr key={usuario.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-medium text-sm">
{usuario.nome?.charAt(0)?.toUpperCase() || 'U'}
</span>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{usuario.nome}
</div>
<div className="text-sm text-gray-500">
{usuario.email}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{usuario.empresaData?.['razao-social'] || 'Não informado'}
</div>
<div className="text-sm text-gray-500">
{usuario.empresaData?.cnpj || ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{new Date(usuario.createdAt).toLocaleDateString('pt-BR')}
</div>
<div className="text-sm text-gray-500">
{new Date(usuario.createdAt).toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
<div className="grid gap-6">
{empresas.map((empresa) => (
<div
key={empresa.id}
className="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-200 hover:shadow-md transition-shadow"
>
<div className="px-4 py-5 sm:p-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-medium leading-6 text-gray-900">
{empresa.razao_social || empresa.nome_fantasia || "Sem Nome"}
</h3>
<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
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 text-sm text-gray-500">
<p><span className="font-medium text-gray-700">CNPJ:</span> {empresa.cnpj}</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 className="flex items-center gap-3 w-full md:w-auto mt-4 md:mt-0">
<button
onClick={() => verDetalhes(usuario)}
className="text-blue-600 hover:text-blue-900 bg-blue-50 hover:bg-blue-100 px-3 py-1 rounded-md transition-colors"
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"
>
Ver Detalhes
</button>
<button
onClick={() => ativarUsuario(usuario.id)}
disabled={activatingUserId === usuario.id}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-3 py-1 rounded-md transition-colors flex items-center gap-1"
>
{activatingUserId === usuario.id ? (
<>
<div className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin"></div>
Ativando...
</>
) : (
<>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Ativar
</>
)}
<CheckCircleIcon className="-ml-0.5 mr-1.5 h-5 w-5" aria-hidden="true" />
Aprovar Acesso
</button>
</div>
</td>
</tr>
</div>
</div>
</div>
))}
</tbody>
</table>
</div>
{/* Paginação */}
{totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Anterior
</button>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Próximo
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Página <span className="font-medium">{page}</span> de{' '}
<span className="font-medium">{totalPages}</span>
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<span className="sr-only">Anterior</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
<span className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
{page}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<span className="sr-only">Próximo</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Modal de Detalhes do Usuário */}
{showDetailsModal && selectedUser && (
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center p-4 z-50 overflow-auto">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[calc(100vh-4rem)] overflow-hidden flex flex-col">
{/* Header do Modal (sticky para não sumir ao rolar) */}
<div className="bg-blue-600 p-6 rounded-t-lg sticky top-0 z-10">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-white">
Detalhes do Usuário
</h2>
<button
onClick={closeDetailsModal}
className="text-white hover:text-gray-200"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Conteúdo do Modal - scrollable */}
<div className="p-6 space-y-6 overflow-y-auto flex-1">
{/* Informações Pessoais */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Informações Pessoais
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Nome</label>
<p className="text-sm text-gray-900">{selectedUser.nome}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Email</label>
<p className="text-sm text-gray-900">{selectedUser.email}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">CPF</label>
<p className="text-sm text-gray-900">{selectedUser.cpf || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Telefone</label>
<p className="text-sm text-gray-900">{selectedUser.telefone || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Nível</label>
<p className="text-sm text-gray-900">{selectedUser.nivel}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Data de Cadastro</label>
<p className="text-sm text-gray-900">
{new Date(selectedUser.createdAt).toLocaleString('pt-BR')}
</p>
</div>
</div>
</div>
{/* Endereço */}
{selectedUser.enderecoData && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Endereço
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">CEP</label>
<p className="text-sm text-gray-900">{selectedUser.enderecoData.cep}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Logradouro</label>
<p className="text-sm text-gray-900">
{selectedUser.enderecoData.logradouro}, {selectedUser.enderecoData.numero}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Complemento</label>
<p className="text-sm text-gray-900">{selectedUser.enderecoData.complemento || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Bairro</label>
<p className="text-sm text-gray-900">{selectedUser.enderecoData.bairro}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Cidade</label>
<p className="text-sm text-gray-900">{selectedUser.enderecoData.cidade}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Estado</label>
<p className="text-sm text-gray-900">{selectedUser.enderecoData.estado}</p>
</div>
</div>
</div>
)}
{/* Empresa */}
{selectedUser.empresaData && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Dados da Empresa
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Razão Social</label>
<p className="text-sm text-gray-900">{selectedUser.empresaData['razao-social']}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Nome Fantasia</label>
<p className="text-sm text-gray-900">{selectedUser.empresaData['nome-fantasia'] || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">CNPJ</label>
<p className="text-sm text-gray-900">{selectedUser.empresaData.cnpj}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Data de Abertura</label>
<p className="text-sm text-gray-900">{selectedUser.empresaData['data-abertura'] || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Situação</label>
<p className="text-sm text-gray-900">{selectedUser.empresaData.situacao || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Natureza Jurídica</label>
<p className="text-sm text-gray-900">{selectedUser.empresaData['natureza-juridica'] || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Porte</label>
<p className="text-sm text-gray-900">{selectedUser.empresaData.porte || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Capital Social</label>
<p className="text-sm text-gray-900">
{selectedUser.empresaData['capital-social']
? `R$ ${selectedUser.empresaData['capital-social'].toLocaleString('pt-BR')}`
: 'Não informado'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Telefone</label>
<p className="text-sm text-gray-900">{selectedUser.empresaData.telefone || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Email</label>
<p className="text-sm text-gray-900">{selectedUser.empresaData.email || 'Não informado'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Atividade Principal</label>
<p className="text-sm text-gray-900">
{selectedUser.empresaData['atividade-principal-codigo'] || selectedUser.empresaData['atividade-principal-desc']
? `${selectedUser.empresaData['atividade-principal-codigo']} - ${selectedUser.empresaData['atividade-principal-desc']}`
: 'Não informado'}
</p>
</div>
</div>
</div>
)}
</div>
{/* Footer do Modal (sticky para ficar visível) */}
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3 sticky bottom-0 z-10">
<button
onClick={closeDetailsModal}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors"
>
Fechar
</button>
<button
onClick={() => ativarUsuario(selectedUser.id)}
disabled={activatingUserId === selectedUser.id}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors flex items-center gap-2"
>
{activatingUserId === selectedUser.id ? (
<>
<div className="w-4 h-4 border border-white border-t-transparent rounded-full animate-spin"></div>
Ativando...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Ativar Usuário
</>
)}
</button>
</div>
</div>
</div>
)}
</main>
</div>
);
};
export default UsuariosPendentesPage;
}

View file

@ -44,6 +44,7 @@ const Header = ({
user?.name ||
"Usuário";
const displayCompanyName =
user?.company_name ||
user?.empresa?.["nome-fantasia"] ||
user?.empresa?.["razao-social"] ||
user?.empresa?.nomeFantasia ||
@ -228,6 +229,9 @@ const Header = ({
</svg>
</button>
{isOpen && (
<>
{/* Bridge do prevent closing */}
<div className="absolute top-full right-0 w-48 h-2 bg-transparent z-10" />
<ul className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg z-10 border">
<li>
<div className="px-4 py-3 border-b rounded-t-lg">
@ -315,6 +319,7 @@ const Header = ({
</button>
</li>
</ul>
</>
)}
</div>
</div>

View file

@ -58,6 +58,52 @@ export const empresaApiService = {
}
},
/**
* Lista empresas com filtros
* @param filters - Filtros (ex: is_verified=false)
* @returns Lista de empresas
*/
listar: async (filters?: Record<string, any>): Promise<EmpresaBff[]> => {
try {
const token = localStorage.getItem('access_token');
if (!token) throw new Error('Token não encontrado');
const querySource = filters || {};
const queryParams = new URLSearchParams();
Object.keys(querySource).forEach(key => {
if (querySource[key] !== undefined && querySource[key] !== null) {
queryParams.append(key, String(querySource[key]));
}
});
const response = await fetch(`${BFF_BASE_URL}/empresas?${queryParams.toString()}`, {
method: 'GET',
headers: {
'accept': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
console.error(`❌ Erro ao listar empresas: ${response.status}`);
return [];
}
const data = await response.json();
const items = data.tenants || (Array.isArray(data) ? data : []);
// Mapear campos do backend para o frontend
return items.map((item: any) => ({
...item,
razao_social: item.corporate_name || item.razao_social, // Fallback
nome_fantasia: item.trade_name || item.nome_fantasia || "",
}));
} catch (error) {
console.error('❌ Erro ao listar empresas:', error);
return [];
}
},
/**
* Atualiza dados da empresa
* @param empresaId - ID da empresa

View file

@ -19,6 +19,7 @@ export interface UserData {
"nome-social": string | null;
cpf: string;
email: string;
company_name?: string;
nivel: UserRole;
empresas?: any[];
enderecos?: any[];