Merge pull request #20 from rede5/codex/implementar-paginacao-de-usuarios

Add paginated users listing (backend + frontend, 10 per page)
This commit is contained in:
Tiago Yamamoto 2025-12-22 16:44:48 -03:00 committed by GitHub
commit 470139da12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 189 additions and 77 deletions

View file

@ -717,6 +717,20 @@ const docTemplate = `{
"consumes": [
"application/json"
],
"parameters": [
{
"type": "integer",
"description": "Page number (default: 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Items per page (default: 10, max: 100)",
"name": "limit",
"in": "query"
}
],
"produces": [
"application/json"
],
@ -728,10 +742,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.UserResponse"
}
"$ref": "#/definitions/dto.PaginatedResponse"
}
},
"403": {

View file

@ -710,6 +710,20 @@
"consumes": [
"application/json"
],
"parameters": [
{
"type": "integer",
"description": "Page number (default: 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Items per page (default: 10, max: 100)",
"name": "limit",
"in": "query"
}
],
"produces": [
"application/json"
],
@ -721,10 +735,7 @@
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.UserResponse"
}
"$ref": "#/definitions/dto.PaginatedResponse"
}
},
"403": {
@ -1423,4 +1434,4 @@
}
}
}
}
}

View file

@ -857,15 +857,22 @@ paths:
consumes:
- application/json
description: Returns a list of users belonging to the authenticated tenant.
parameters:
- description: Page number (default: 1)
in: query
name: page
type: integer
- description: Items per page (default: 10, max: 100)
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/dto.UserResponse'
type: array
$ref: '#/definitions/dto.PaginatedResponse'
"403":
description: Forbidden
schema:

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"net"
"net/http"
"strconv"
"strings"
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
@ -173,7 +174,9 @@ func (h *CoreHandlers) CreateUser(w http.ResponseWriter, r *http.Request) {
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {array} dto.UserResponse
// @Param page query int false "Page number (default: 1)"
// @Param limit query int false "Items per page (default: 10, max: 100)"
// @Success 200 {object} dto.PaginatedResponse
// @Failure 403 {string} string "Forbidden"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/users [get]
@ -185,7 +188,10 @@ func (h *CoreHandlers) ListUsers(w http.ResponseWriter, r *http.Request) {
return
}
users, err := h.listUsersUC.Execute(ctx, tenantID)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
users, err := h.listUsersUC.Execute(ctx, tenantID, page, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View file

@ -19,7 +19,7 @@ type UserRepository interface {
FindByID(ctx context.Context, id string) (*entity.User, error)
FindByEmail(ctx context.Context, email string) (*entity.User, error)
// FindAllByTenant returns users strictly scoped to a tenant
FindAllByTenant(ctx context.Context, tenantID string) ([]*entity.User, error)
FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error)
Update(ctx context.Context, user *entity.User) (*entity.User, error)
Delete(ctx context.Context, id string) error
}

View file

@ -17,8 +17,22 @@ func NewListUsersUseCase(uRepo ports.UserRepository) *ListUsersUseCase {
}
}
func (uc *ListUsersUseCase) Execute(ctx context.Context, tenantID string) ([]dto.UserResponse, error) {
users, err := uc.userRepo.FindAllByTenant(ctx, tenantID)
func (uc *ListUsersUseCase) Execute(ctx context.Context, tenantID string, page, limit int) (*dto.PaginatedResponse, error) {
if page <= 0 {
page = 1
}
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
if offset < 0 {
offset = 0
}
users, total, err := uc.userRepo.FindAllByTenant(ctx, tenantID, limit, offset)
if err != nil {
return nil, err
}
@ -40,5 +54,12 @@ func (uc *ListUsersUseCase) Execute(ctx context.Context, tenantID string) ([]dto
})
}
return response, nil
return &dto.PaginatedResponse{
Data: response,
Pagination: dto.Pagination{
Page: page,
Limit: limit,
Total: total,
},
}, nil
}

View file

@ -96,11 +96,21 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User,
return u, nil
}
func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) ([]*entity.User, error) {
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE tenant_id = $1`
rows, err := r.db.QueryContext(ctx, query, tenantID)
func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error) {
var total int
countQuery := `SELECT COUNT(*) FROM core_users WHERE tenant_id = $1`
if err := r.db.QueryRowContext(ctx, countQuery, tenantID).Scan(&total); err != nil {
return nil, 0, err
}
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at
FROM core_users
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`
rows, err := r.db.QueryContext(ctx, query, tenantID, limit, offset)
if err != nil {
return nil, err
return nil, 0, err
}
defer rows.Close()
@ -108,13 +118,13 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) (
for rows.Next() {
u := &entity.User{}
if err := rows.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, err
return nil, 0, err
}
// Populate roles N+1? Ideally join, but for now simple
u.Roles, _ = r.getRoles(ctx, u.ID)
users = append(users, u)
}
return users, nil
return users, total, nil
}
func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) {

View file

@ -28,6 +28,8 @@ export default function AdminUsersPage() {
const [users, setUsers] = useState<ApiUser[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState("")
const [page, setPage] = useState(1)
const [totalUsers, setTotalUsers] = useState(0)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [formData, setFormData] = useState({
@ -46,11 +48,16 @@ export default function AdminUsersPage() {
loadUsers()
}, [router])
const loadUsers = async () => {
const limit = 10
const totalPages = Math.max(1, Math.ceil(totalUsers / limit))
const loadUsers = async (targetPage = page) => {
try {
setLoading(true)
const data = await usersApi.list()
setUsers(data || [])
const data = await usersApi.list({ page: targetPage, limit })
setUsers(data?.data || [])
setTotalUsers(data?.pagination?.total || 0)
setPage(data?.pagination?.page || targetPage)
} catch (error) {
console.error("Error loading users:", error)
toast.error("Failed to load users")
@ -66,7 +73,8 @@ export default function AdminUsersPage() {
toast.success("User created successfully!")
setIsDialogOpen(false)
setFormData({ name: "", email: "", password: "", role: "jobSeeker" })
loadUsers()
setPage(1)
loadUsers(1)
} catch (error) {
console.error("Error creating user:", error)
toast.error("Failed to create user")
@ -80,7 +88,9 @@ export default function AdminUsersPage() {
try {
await usersApi.delete(id)
toast.success("User deleted!")
loadUsers()
const nextPage = page > 1 && users.length === 1 ? page - 1 : page
setPage(nextPage)
loadUsers(nextPage)
} catch (error) {
console.error("Error deleting user:", error)
toast.error("Failed to delete user")
@ -201,12 +211,12 @@ export default function AdminUsersPage() {
<Card>
<CardHeader className="pb-3">
<CardDescription>Total users</CardDescription>
<CardTitle className="text-3xl">{users.length}</CardTitle>
<CardTitle className="text-3xl">{totalUsers}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Admins</CardDescription>
<CardDescription>Admins (page)</CardDescription>
<CardTitle className="text-3xl">
{users.filter((u) => u.role === "superadmin" || u.role === "companyAdmin").length}
</CardTitle>
@ -214,13 +224,13 @@ export default function AdminUsersPage() {
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Recruiters</CardDescription>
<CardDescription>Recruiters (page)</CardDescription>
<CardTitle className="text-3xl">{users.filter((u) => u.role === "recruiter").length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Candidates</CardDescription>
<CardDescription>Candidates (page)</CardDescription>
<CardTitle className="text-3xl">{users.filter((u) => u.role === "jobSeeker").length}</CardTitle>
</CardHeader>
</Card>
@ -247,53 +257,83 @@ export default function AdminUsersPage() {
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No users found
</TableCell>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{getRoleBadge(user.role)}</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "secondary"}>
{user.status === "active" ? "Active" : user.status}
</Badge>
</TableCell>
<TableCell>
{user.created_at ? new Date(user.created_at).toLocaleDateString("en-US") : "-"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.id)}
disabled={user.role === "superadmin"}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No users found
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{getRoleBadge(user.role)}</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "secondary"}>
{user.status === "active" ? "Active" : user.status}
</Badge>
</TableCell>
<TableCell>
{user.created_at ? new Date(user.created_at).toLocaleDateString("en-US") : "-"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(user.id)}
disabled={user.role === "superadmin"}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>
{totalUsers === 0
? "No users to display"
: `Showing ${(page - 1) * limit + 1}-${Math.min(page * limit, totalUsers)} of ${totalUsers}`}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => loadUsers(page - 1)}
disabled={page <= 1 || loading}
>
Previous
</Button>
<span>
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => loadUsers(page + 1)}
disabled={page >= totalPages || loading}
>
Next
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>

View file

@ -71,7 +71,13 @@ async function apiRequest<T>(
// Users API
export const usersApi = {
list: () => apiRequest<ApiUser[]>("/api/v1/users"),
list: (params?: { page?: number; limit?: number }) => {
const query = new URLSearchParams();
if (params?.page) query.set("page", String(params.page));
if (params?.limit) query.set("limit", String(params.limit));
const queryStr = query.toString();
return apiRequest<PaginatedResponse<ApiUser>>(`/api/v1/users${queryStr ? `?${queryStr}` : ""}`);
},
create: (data: { name: string; email: string; password: string; role: string }) =>
apiRequest<ApiUser>("/api/v1/users", {