feat: Adicionado gerenciamento de usuários administradores, funcionalidade de código de acesso e documentação da API.

This commit is contained in:
NANDO9322 2025-12-29 16:51:55 -03:00
parent 030b78d787
commit 5312945f7c
14 changed files with 923 additions and 5 deletions

View file

@ -10,6 +10,7 @@ import (
"photum-backend/internal/auth" "photum-backend/internal/auth"
"photum-backend/internal/availability" "photum-backend/internal/availability"
"photum-backend/internal/cadastro_fot" "photum-backend/internal/cadastro_fot"
"photum-backend/internal/codigos"
"photum-backend/internal/config" "photum-backend/internal/config"
"photum-backend/internal/cursos" "photum-backend/internal/cursos"
"photum-backend/internal/db" "photum-backend/internal/db"
@ -96,6 +97,7 @@ func main() {
availabilityHandler := availability.NewHandler(availabilityService) availabilityHandler := availability.NewHandler(availabilityService)
escalasHandler := escalas.NewHandler(escalas.NewService(queries)) escalasHandler := escalas.NewHandler(escalas.NewService(queries))
logisticaHandler := logistica.NewHandler(logistica.NewService(queries)) logisticaHandler := logistica.NewHandler(logistica.NewService(queries))
codigosHandler := codigos.NewHandler(codigos.NewService(queries))
r := gin.Default() r := gin.Default()
@ -230,6 +232,13 @@ func main() {
logisticaGroup.GET("/carros/:id/passageiros", logisticaHandler.ListPassengers) logisticaGroup.GET("/carros/:id/passageiros", logisticaHandler.ListPassengers)
} }
codigosGroup := api.Group("/codigos-acesso")
{
codigosGroup.POST("", codigosHandler.Create)
codigosGroup.GET("", codigosHandler.List)
codigosGroup.DELETE("/:id", codigosHandler.Delete)
}
admin := api.Group("/admin") admin := api.Group("/admin")
{ {
admin.GET("/users", authHandler.ListUsers) admin.GET("/users", authHandler.ListUsers)

View file

@ -1056,6 +1056,89 @@ const docTemplate = `{
} }
} }
}, },
"/api/codigos-acesso": {
"get": {
"produces": [
"application/json"
],
"tags": [
"codigos"
],
"summary": "List Access Codes",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"codigos"
],
"summary": "Create Access Code",
"parameters": [
{
"description": "Req",
"name": "req",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codigos.CreateCodigoInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/codigos-acesso/{id}": {
"delete": {
"tags": [
"codigos"
],
"summary": "Delete Access Code",
"parameters": [
{
"type": "string",
"description": "ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/cursos": { "/api/cursos": {
"get": { "get": {
"security": [ "security": [
@ -3046,6 +3129,20 @@ const docTemplate = `{
} }
} }
}, },
"codigos.CreateCodigoInput": {
"type": "object",
"properties": {
"codigo": {
"type": "string"
},
"descricao": {
"type": "string"
},
"validade_dias": {
"type": "integer"
}
}
},
"cursos.CreateCursoRequest": { "cursos.CreateCursoRequest": {
"type": "object", "type": "object",
"required": [ "required": [

View file

@ -1050,6 +1050,89 @@
} }
} }
}, },
"/api/codigos-acesso": {
"get": {
"produces": [
"application/json"
],
"tags": [
"codigos"
],
"summary": "List Access Codes",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"codigos"
],
"summary": "Create Access Code",
"parameters": [
{
"description": "Req",
"name": "req",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codigos.CreateCodigoInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/codigos-acesso/{id}": {
"delete": {
"tags": [
"codigos"
],
"summary": "Delete Access Code",
"parameters": [
{
"type": "string",
"description": "ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/cursos": { "/api/cursos": {
"get": { "get": {
"security": [ "security": [
@ -3040,6 +3123,20 @@
} }
} }
}, },
"codigos.CreateCodigoInput": {
"type": "object",
"properties": {
"codigo": {
"type": "string"
},
"descricao": {
"type": "string"
},
"validade_dias": {
"type": "integer"
}
}
},
"cursos.CreateCursoRequest": { "cursos.CreateCursoRequest": {
"type": "object", "type": "object",
"required": [ "required": [

View file

@ -210,6 +210,15 @@ definitions:
pre_venda: pre_venda:
type: boolean type: boolean
type: object type: object
codigos.CreateCodigoInput:
properties:
codigo:
type: string
descricao:
type: string
validade_dias:
type: integer
type: object
cursos.CreateCursoRequest: cursos.CreateCursoRequest:
properties: properties:
nome: nome:
@ -1176,6 +1185,60 @@ paths:
summary: Update FOT record summary: Update FOT record
tags: tags:
- cadastro_fot - cadastro_fot
/api/codigos-acesso:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
additionalProperties: true
type: object
type: array
summary: List Access Codes
tags:
- codigos
post:
consumes:
- application/json
parameters:
- description: Req
in: body
name: req
required: true
schema:
$ref: '#/definitions/codigos.CreateCodigoInput'
produces:
- application/json
responses:
"201":
description: Created
schema:
additionalProperties: true
type: object
summary: Create Access Code
tags:
- codigos
/api/codigos-acesso/{id}:
delete:
parameters:
- description: ID
in: path
name: id
required: true
type: string
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
summary: Delete Access Code
tags:
- codigos
/api/cursos: /api/cursos:
get: get:
consumes: consumes:

View file

@ -0,0 +1,90 @@
package codigos
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// Create godoc
// @Summary Create Access Code
// @Tags codigos
// @Accept json
// @Produce json
// @Param req body CreateCodigoInput true "Req"
// @Success 201 {object} map[string]interface{}
// @Router /api/codigos-acesso [post]
func (h *Handler) Create(c *gin.Context) {
var req CreateCodigoInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
code, err := h.service.Create(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": uuid.UUID(code.ID.Bytes).String(), "codigo": code.Codigo})
}
// List godoc
// @Summary List Access Codes
// @Tags codigos
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Router /api/codigos-acesso [get]
func (h *Handler) List(c *gin.Context) {
codes, err := h.service.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resp := make([]map[string]interface{}, len(codes))
for i, v := range codes {
resp[i] = map[string]interface{}{
"id": uuid.UUID(v.ID.Bytes).String(),
"codigo": v.Codigo,
"descricao": v.Descricao.String,
"validade_dias": v.ValidadeDias,
"criado_em": v.CriadoEm.Time,
"expira_em": v.ExpiraEm.Time,
"ativo": v.Ativo,
"usos": v.Usos,
}
}
c.JSON(http.StatusOK, resp)
}
// Delete godoc
// @Summary Delete Access Code
// @Tags codigos
// @Param id path string true "ID"
// @Success 200 {object} map[string]string
// @Router /api/codigos-acesso/{id} [delete]
func (h *Handler) Delete(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
err := h.service.Delete(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}

View file

@ -0,0 +1,79 @@
package codigos
import (
"context"
"time"
"photum-backend/internal/db/generated"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Service struct {
q *generated.Queries
}
func NewService(q *generated.Queries) *Service {
return &Service{
q: q,
}
}
type CreateCodigoInput struct {
Codigo string `json:"codigo"`
Descricao string `json:"descricao"`
ValidadeDias int32 `json:"validade_dias"`
}
func (s *Service) Create(ctx context.Context, input CreateCodigoInput) (generated.CodigosAcesso, error) {
days := input.ValidadeDias
var expiraEm time.Time
if days == -1 {
// Infinite: 100 years
expiraEm = time.Now().AddDate(100, 0, 0)
} else {
if days <= 0 {
days = 30
}
expiraEm = time.Now().Add(time.Duration(days) * 24 * time.Hour)
}
return s.q.CreateCodigoAcesso(ctx, generated.CreateCodigoAcessoParams{
Codigo: input.Codigo,
Descricao: pgtype.Text{String: input.Descricao, Valid: input.Descricao != ""},
ValidadeDias: days,
ExpiraEm: pgtype.Timestamptz{Time: expiraEm, Valid: true},
Ativo: true,
})
}
func (s *Service) List(ctx context.Context) ([]generated.CodigosAcesso, error) {
return s.q.ListCodigosAcesso(ctx)
}
func (s *Service) Delete(ctx context.Context, id string) error {
uid, err := uuid.Parse(id)
if err != nil {
return err
}
// Convert uuid.UUID to pgtype.UUID
var pgUUID pgtype.UUID
pgUUID.Bytes = uid
pgUUID.Valid = true
return s.q.DeleteCodigoAcesso(ctx, pgUUID)
}
func (s *Service) GetByCode(ctx context.Context, code string) (generated.CodigosAcesso, error) {
return s.q.GetCodigoAcesso(ctx, code)
}
func (s *Service) IncrementUse(ctx context.Context, id uuid.UUID) error {
var pgUUID pgtype.UUID
pgUUID.Bytes = id
pgUUID.Valid = true
return s.q.IncrementCodigoAcessoUso(ctx, pgUUID)
}

View file

@ -0,0 +1,127 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: codigos_acesso.sql
package generated
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createCodigoAcesso = `-- name: CreateCodigoAcesso :one
INSERT INTO codigos_acesso (
codigo, descricao, validade_dias, expira_em, ativo
) VALUES (
$1, $2, $3, $4, $5
)
RETURNING id, codigo, descricao, validade_dias, criado_em, expira_em, ativo, usos
`
type CreateCodigoAcessoParams struct {
Codigo string `json:"codigo"`
Descricao pgtype.Text `json:"descricao"`
ValidadeDias int32 `json:"validade_dias"`
ExpiraEm pgtype.Timestamptz `json:"expira_em"`
Ativo bool `json:"ativo"`
}
func (q *Queries) CreateCodigoAcesso(ctx context.Context, arg CreateCodigoAcessoParams) (CodigosAcesso, error) {
row := q.db.QueryRow(ctx, createCodigoAcesso,
arg.Codigo,
arg.Descricao,
arg.ValidadeDias,
arg.ExpiraEm,
arg.Ativo,
)
var i CodigosAcesso
err := row.Scan(
&i.ID,
&i.Codigo,
&i.Descricao,
&i.ValidadeDias,
&i.CriadoEm,
&i.ExpiraEm,
&i.Ativo,
&i.Usos,
)
return i, err
}
const deleteCodigoAcesso = `-- name: DeleteCodigoAcesso :exec
DELETE FROM codigos_acesso
WHERE id = $1
`
func (q *Queries) DeleteCodigoAcesso(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteCodigoAcesso, id)
return err
}
const getCodigoAcesso = `-- name: GetCodigoAcesso :one
SELECT id, codigo, descricao, validade_dias, criado_em, expira_em, ativo, usos FROM codigos_acesso
WHERE codigo = $1 LIMIT 1
`
func (q *Queries) GetCodigoAcesso(ctx context.Context, codigo string) (CodigosAcesso, error) {
row := q.db.QueryRow(ctx, getCodigoAcesso, codigo)
var i CodigosAcesso
err := row.Scan(
&i.ID,
&i.Codigo,
&i.Descricao,
&i.ValidadeDias,
&i.CriadoEm,
&i.ExpiraEm,
&i.Ativo,
&i.Usos,
)
return i, err
}
const incrementCodigoAcessoUso = `-- name: IncrementCodigoAcessoUso :exec
UPDATE codigos_acesso
SET usos = usos + 1
WHERE id = $1
`
func (q *Queries) IncrementCodigoAcessoUso(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, incrementCodigoAcessoUso, id)
return err
}
const listCodigosAcesso = `-- name: ListCodigosAcesso :many
SELECT id, codigo, descricao, validade_dias, criado_em, expira_em, ativo, usos FROM codigos_acesso
ORDER BY criado_em DESC
`
func (q *Queries) ListCodigosAcesso(ctx context.Context) ([]CodigosAcesso, error) {
rows, err := q.db.Query(ctx, listCodigosAcesso)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CodigosAcesso
for rows.Next() {
var i CodigosAcesso
if err := rows.Scan(
&i.ID,
&i.Codigo,
&i.Descricao,
&i.ValidadeDias,
&i.CriadoEm,
&i.ExpiraEm,
&i.Ativo,
&i.Usos,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View file

@ -124,6 +124,17 @@ type CadastroProfissionai struct {
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
} }
type CodigosAcesso struct {
ID pgtype.UUID `json:"id"`
Codigo string `json:"codigo"`
Descricao pgtype.Text `json:"descricao"`
ValidadeDias int32 `json:"validade_dias"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
ExpiraEm pgtype.Timestamptz `json:"expira_em"`
Ativo bool `json:"ativo"`
Usos int32 `json:"usos"`
}
type Curso struct { type Curso struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Nome string `json:"nome"` Nome string `json:"nome"`

View file

@ -0,0 +1,24 @@
-- name: CreateCodigoAcesso :one
INSERT INTO codigos_acesso (
codigo, descricao, validade_dias, expira_em, ativo
) VALUES (
$1, $2, $3, $4, $5
)
RETURNING *;
-- name: ListCodigosAcesso :many
SELECT * FROM codigos_acesso
ORDER BY criado_em DESC;
-- name: DeleteCodigoAcesso :exec
DELETE FROM codigos_acesso
WHERE id = $1;
-- name: GetCodigoAcesso :one
SELECT * FROM codigos_acesso
WHERE codigo = $1 LIMIT 1;
-- name: IncrementCodigoAcessoUso :exec
UPDATE codigos_acesso
SET usos = usos + 1
WHERE id = $1;

View file

@ -421,3 +421,15 @@ CREATE TABLE IF NOT EXISTS logistica_passageiros (
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(carro_id, profissional_id) UNIQUE(carro_id, profissional_id)
); );
-- Codigos de Acesso Table
CREATE TABLE IF NOT EXISTS codigos_acesso (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
codigo VARCHAR(50) UNIQUE NOT NULL,
descricao VARCHAR(255),
validade_dias INT NOT NULL DEFAULT 30,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expira_em TIMESTAMPTZ NOT NULL,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
usos INT NOT NULL DEFAULT 0
);

View file

@ -0,0 +1,12 @@
-- Codigos de Acesso Table
CREATE TABLE IF NOT EXISTS codigos_acesso (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
codigo VARCHAR(50) UNIQUE NOT NULL,
descricao VARCHAR(255),
validade_dias INT NOT NULL DEFAULT 30,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expira_em TIMESTAMPTZ NOT NULL,
ativo BOOLEAN NOT NULL DEFAULT TRUE,
usos INT NOT NULL DEFAULT 0
);

View file

@ -21,7 +21,7 @@ import { SettingsPage } from "./pages/Settings";
import { CourseManagement } from "./pages/CourseManagement"; import { CourseManagement } from "./pages/CourseManagement";
import { InspirationPage } from "./pages/Inspiration"; import { InspirationPage } from "./pages/Inspiration";
import { UserApproval } from "./pages/UserApproval"; import { UserApproval } from "./pages/UserApproval";
import { AccessCodeManagement } from "./pages/AccessCodeManagement"; import { AccessCodes } from "./pages/AccessCodes";
import { PrivacyPolicy } from "./pages/PrivacyPolicy"; import { PrivacyPolicy } from "./pages/PrivacyPolicy";
import { TermsOfUse } from "./pages/TermsOfUse"; import { TermsOfUse } from "./pages/TermsOfUse";
import { LGPD } from "./pages/LGPD"; import { LGPD } from "./pages/LGPD";
@ -540,7 +540,7 @@ const AppContent: React.FC = () => {
allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]} allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]}
> >
<PageWrapper> <PageWrapper>
<AccessCodeManagement onNavigate={(page) => { }} /> <AccessCodes />
</PageWrapper> </PageWrapper>
</ProtectedRoute> </ProtectedRoute>
} }

View file

@ -0,0 +1,246 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { createAccessCode, listAccessCodes, deleteAccessCode } from '../services/apiService';
import { Plus, Trash2, Copy, Check } from 'lucide-react';
import { UserRole } from '../types';
interface AccessCode {
id: string;
codigo: string;
descricao: string;
validade_dias: number;
criado_em: string;
expira_em: string;
ativo: boolean;
usos: number;
}
export const AccessCodes: React.FC = () => {
const { token, user } = useAuth();
const [codes, setCodes] = useState<AccessCode[]>([]);
const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
// Form State
const [newCode, setNewCode] = useState('');
const [days, setDays] = useState(30);
const [customDays, setCustomDays] = useState('');
const [copiedId, setCopiedId] = useState<string | null>(null);
useEffect(() => {
if (token) fetchCodes();
}, [token]);
const fetchCodes = async () => {
setLoading(true);
const res = await listAccessCodes(token!);
if (res.data) {
setCodes(res.data);
}
setLoading(false);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
const codeToCreate = newCode.trim() || generateRandomCode();
const res = await createAccessCode(token!, {
codigo: codeToCreate,
validade_dias: days,
descricao: 'Gerado via Painel'
});
if (res.error) {
alert('Erro ao criar: ' + res.error);
} else {
setIsCreateModalOpen(false);
setNewCode('');
fetchCodes();
}
};
const handleDelete = async (id: string) => {
if (!confirm('Excluir este código? Ele não poderá mais ser usado.')) return;
const res = await deleteAccessCode(token!, id);
if (!res.error) {
fetchCodes();
} else {
alert('Erro ao excluir');
}
};
const generateRandomCode = () => {
const year = new Date().getFullYear();
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `PHOTUM${year}-${random}`;
};
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
if (!user || (user.role !== UserRole.SUPERADMIN && user.role !== UserRole.BUSINESS_OWNER)) {
return <div className="p-8 text-center">Acesso restrito.</div>;
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-2">Gerenciar Códigos de Acesso</h1>
<p className="text-gray-600">Crie e gerencie códigos de acesso temporários para novos cadastros</p>
</div>
<button
onClick={() => setIsCreateModalOpen(true)}
className="bg-[#492E61] text-white px-6 py-3 rounded-lg font-medium hover:bg-[#5a3a7a] transition-colors flex items-center gap-2 mb-8 shadow-sm"
>
<Plus size={20} />
Gerar Novo Código
</button>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Código</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Validade (Dias)</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Data de Criação</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Data de Expiração</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr><td colSpan={6} className="px-6 py-8 text-center text-gray-500">Carregando...</td></tr>
) : codes.length === 0 ? (
<tr><td colSpan={6} className="px-6 py-8 text-center text-gray-500">Nenhum código gerado.</td></tr>
) : (
codes.map(code => (
<tr key={code.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<span className={`inline-block px-3 py-1 text-xs font-medium rounded-full ${code.ativo ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{code.validade_dias === -1 ? 'Infinito' : `${code.validade_dias} dias`}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 group">
<span className="font-mono font-bold text-[#492E61]">{code.codigo}</span>
<button
onClick={() => copyToClipboard(code.codigo, code.id)}
className="text-gray-400 hover:text-[#492E61] opacity-0 group-hover:opacity-100 transition-opacity"
title="Copiar Código"
>
{copiedId === code.id ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
</td>
<td className="px-6 py-4 text-gray-600">{code.validade_dias === -1 ? 'Nunca expira' : `${code.validade_dias} dias`}</td>
<td className="px-6 py-4 text-sm text-gray-500">{formatDate(code.criado_em)}</td>
<td className="px-6 py-4 text-sm text-gray-500">{code.validade_dias === -1 ? '-' : formatDate(code.expira_em)}</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleDelete(code.id)}
className="inline-flex items-center px-3 py-1.5 bg-red-50 text-red-600 rounded-md hover:bg-red-100 transition-colors text-sm font-medium"
>
<Trash2 size={14} className="mr-1.5" />
Excluir
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Modal Create */}
{isCreateModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 animate-fadeIn">
<h2 className="text-xl font-bold mb-4">Novo Código de Acesso</h2>
<form onSubmit={handleCreate}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Código (Opcional)</label>
<input
type="text"
value={newCode}
onChange={e => setNewCode(e.target.value.toUpperCase())}
placeholder={generateRandomCode()}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#492E61] focus:border-transparent outline-none"
/>
<p className="text-xs text-gray-500 mt-1">Deixe em branco para gerar automaticamente.</p>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">Validade</label>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{[7, 15, 30, 60, 90].map(d => (
<button
key={d}
type="button"
onClick={() => { setDays(d); setCustomDays(''); }}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${days === d ? 'bg-[#492E61] text-white border-[#492E61]' : 'bg-white text-gray-700 border-gray-200 hover:border-[#492E61]'}`}
>
{d} dias
</button>
))}
<button
type="button"
onClick={() => { setDays(-1); setCustomDays(''); }}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${days === -1 ? 'bg-[#492E61] text-white border-[#492E61]' : 'bg-white text-gray-700 border-gray-200 hover:border-[#492E61]'}`}
>
Nunca Expira
</button>
</div>
{days !== -1 && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Outro:</span>
<input
type="number"
min="1"
value={customDays}
onChange={e => {
const val = parseInt(e.target.value);
setCustomDays(e.target.value);
if (!isNaN(val) && val > 0) setDays(val);
}}
className="w-24 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-[#492E61] focus:border-transparent outline-none"
placeholder="Dias"
/>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setIsCreateModalOpen(false)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-[#492E61] text-white rounded-lg hover:bg-[#5a3a7a]"
>
Gerar Código
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View file

@ -802,8 +802,7 @@ export async function getUploadURL(filename: string, contentType: string): Promi
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); throw new Error(`HTTP error! status: ${response.status}`);
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
@ -813,7 +812,7 @@ export async function getUploadURL(filename: string, contentType: string): Promi
isBackendDown: false, isBackendDown: false,
}; };
} catch (error) { } catch (error) {
console.error("Error fetching upload URL:", error); console.error("Error getting upload URL:", error);
return { return {
data: null, data: null,
error: error instanceof Error ? error.message : "Erro desconhecido", error: error instanceof Error ? error.message : "Erro desconhecido",
@ -822,6 +821,58 @@ export async function getUploadURL(filename: string, contentType: string): Promi
} }
} }
// Access Codes
export async function createAccessCode(token: string, data: { codigo: string; descricao?: string; validade_dias: number }) {
return mutationFetch(`${API_BASE_URL}/api/codigos-acesso`, "POST", data, token);
}
export async function listAccessCodes(token: string) {
return fetchFromBackendAuthenticated(`${API_BASE_URL}/api/codigos-acesso`, token);
}
export async function deleteAccessCode(token: string, id: string) {
return mutationFetch(`${API_BASE_URL}/api/codigos-acesso/${id}`, "DELETE", {}, token);
}
// Helpers for unified fetch
async function fetchFromBackendAuthenticated(url: string, token: string) {
try {
const res = await fetch(url, {
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
}
});
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
return { data, error: null };
} catch (e: any) {
return { data: null, error: e.message };
}
}
async function mutationFetch(url: string, method: string, body: any, token: string) {
try {
const res = await fetch(url, {
method,
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || res.statusText);
}
const data = await res.json();
return { data, error: null };
} catch (e: any) {
return { data: null, error: e.message };
}
}
/** /**
* Realiza o upload do arquivo para a URL pré-assinada * Realiza o upload do arquivo para a URL pré-assinada
*/ */