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:
commit
470139da12
9 changed files with 189 additions and 77 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
Loading…
Reference in a new issue