From a325d612cb85f3450076518fd7a5d01ebaeeba97 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Sun, 19 Oct 2025 16:08:46 -0300 Subject: [PATCH] =?UTF-8?q?admin:=20split=20Equipe/Usu=C3=A1rios,=20add=20?= =?UTF-8?q?bulk=20select/actions=20for=20users,=20machines=20and=20invites?= =?UTF-8?q?;=20add=20company/tenant=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/admin-users-manager.tsx | 657 ++++++++++++++++++- 1 file changed, 641 insertions(+), 16 deletions(-) diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index 41625ce..407060f 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -2,7 +2,7 @@ import Link from "next/link" import { useCallback, useEffect, useMemo, useState, useTransition } from "react" -import { IconSearch, IconUserPlus } from "@tabler/icons-react" +import { IconSearch, IconUserPlus, IconTrash } from "@tabler/icons-react" import { toast } from "sonner" @@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" import { Select, SelectContent, @@ -221,14 +222,39 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d }) return Array.from(unique) }, [normalizedRoles, viewerIsAdmin]) - const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users]) + // Split users: team (admin/agent) and people (manager/collaborator); exclude machines + const teamUsers = useMemo( + () => users.filter((user) => user.role !== "machine" && ["admin", "agent"].includes(coerceRole(user.role))), + [users], + ) + const peopleUsers = useMemo( + () => users.filter((user) => user.role !== "machine" && ["manager", "collaborator"].includes(coerceRole(user.role))), + [users], + ) const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users]) const defaultCreateRole: RoleOption = selectableRoles[0] ?? "agent" + // Equipe const [teamSearch, setTeamSearch] = useState("") const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all") + const [teamCompanyFilter, setTeamCompanyFilter] = useState("all") + const [teamTenantFilter, setTeamTenantFilter] = useState("all") + const [teamSelection, setTeamSelection] = useState>(new Set()) + const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false) + const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false) + // Usuários + const [peopleSearch, setPeopleSearch] = useState("") + const [peopleRoleFilter, setPeopleRoleFilter] = useState<"all" | "manager" | "collaborator">("all") + const [peopleCompanyFilter, setPeopleCompanyFilter] = useState("all") + const [peopleTenantFilter, setPeopleTenantFilter] = useState("all") + const [peopleSelection, setPeopleSelection] = useState>(new Set()) + const [isBulkDeletingPeople, setIsBulkDeletingPeople] = useState(false) + const [bulkDeletePeopleOpen, setBulkDeletePeopleOpen] = useState(false) const [machineSearch, setMachineSearch] = useState("") const [machinePersonaFilter, setMachinePersonaFilter] = useState<"all" | "manager" | "collaborator" | "unassigned">("all") + const [machineSelection, setMachineSelection] = useState>(new Set()) + const [isBulkDeletingMachines, setIsBulkDeletingMachines] = useState(false) + const [bulkDeleteMachinesOpen, setBulkDeleteMachinesOpen] = useState(false) const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createForm, setCreateForm] = useState({ name: "", @@ -240,9 +266,19 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d const [isCreatingUser, setIsCreatingUser] = useState(false) const [createPassword, setCreatePassword] = useState(null) + // Options of tenants present in dataset for filtering + const tenantOptions = useMemo(() => { + const set = new Set() + users.forEach((u) => u.tenantId && set.add(u.tenantId)) + const list = Array.from(set) + return list.length > 0 ? list : [defaultTenantId] + }, [users, defaultTenantId]) + const filteredTeamUsers = useMemo(() => { const term = teamSearch.trim().toLowerCase() return teamUsers.filter((user) => { + if (teamCompanyFilter !== "all" && user.companyId !== teamCompanyFilter) return false + if (teamTenantFilter !== "all" && (user.tenantId ?? defaultTenantId) !== teamTenantFilter) return false if (teamRoleFilter !== "all" && coerceRole(user.role) !== teamRoleFilter) return false if (!term) return true const haystack = [ @@ -255,7 +291,27 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d .toLowerCase() return haystack.includes(term) }) - }, [teamUsers, teamSearch, teamRoleFilter]) + }, [teamUsers, teamSearch, teamRoleFilter, teamCompanyFilter, teamTenantFilter, defaultTenantId]) + + const filteredPeopleUsers = useMemo(() => { + const term = peopleSearch.trim().toLowerCase() + return peopleUsers.filter((user) => { + const role = coerceRole(user.role) + if (peopleRoleFilter !== "all" && role !== peopleRoleFilter) return false + if (peopleCompanyFilter !== "all" && user.companyId !== peopleCompanyFilter) return false + if (peopleTenantFilter !== "all" && (user.tenantId ?? defaultTenantId) !== peopleTenantFilter) return false + if (!term) return true + const haystack = [ + user.name ?? "", + user.email ?? "", + user.companyName ?? "", + formatRole(user.role), + ] + .join(" ") + .toLowerCase() + return haystack.includes(term) + }) + }, [peopleUsers, peopleSearch, peopleRoleFilter, peopleCompanyFilter, peopleTenantFilter, defaultTenantId]) const filteredMachineUsers = useMemo(() => { const term = machineSearch.trim().toLowerCase() @@ -582,6 +638,97 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d } } + // Bulk selection helpers + const selectedTeamUsers = useMemo(() => filteredTeamUsers.filter((u) => teamSelection.has(u.id)), [filteredTeamUsers, teamSelection]) + const allTeamSelected = selectedTeamUsers.length > 0 && selectedTeamUsers.length === filteredTeamUsers.length + const someTeamSelected = selectedTeamUsers.length > 0 && !allTeamSelected + + const selectedPeopleUsers = useMemo(() => filteredPeopleUsers.filter((u) => peopleSelection.has(u.id)), [filteredPeopleUsers, peopleSelection]) + const allPeopleSelected = selectedPeopleUsers.length > 0 && selectedPeopleUsers.length === filteredPeopleUsers.length + const somePeopleSelected = selectedPeopleUsers.length > 0 && !allPeopleSelected + + const selectedMachineUsers = useMemo(() => filteredMachineUsers.filter((u) => machineSelection.has(u.id)), [filteredMachineUsers, machineSelection]) + const allMachinesSelected = selectedMachineUsers.length > 0 && selectedMachineUsers.length === filteredMachineUsers.length + const someMachinesSelected = selectedMachineUsers.length > 0 && !allMachinesSelected + + const [inviteSelection, setInviteSelection] = useState>(new Set()) + const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection]) + const allInvitesSelected = selectedInvites.length > 0 && selectedInvites.length === invites.length + const someInvitesSelected = selectedInvites.length > 0 && !allInvitesSelected + const [isBulkRevokingInvites, setIsBulkRevokingInvites] = useState(false) + const [bulkRevokeInvitesOpen, setBulkRevokeInvitesOpen] = useState(false) + + function toggleTeamSelectAll(checked: boolean) { + setTeamSelection(checked ? new Set(filteredTeamUsers.map((u) => u.id)) : new Set()) + } + function togglePeopleSelectAll(checked: boolean) { + setPeopleSelection(checked ? new Set(filteredPeopleUsers.map((u) => u.id)) : new Set()) + } + function toggleMachinesSelectAll(checked: boolean) { + setMachineSelection(checked ? new Set(filteredMachineUsers.map((u) => u.id)) : new Set()) + } + function toggleInvitesSelectAll(checked: boolean) { + setInviteSelection(checked ? new Set(invites.map((i) => i.id)) : new Set()) + } + + async function performBulkDeleteUsers(ids: string[]) { + if (ids.length === 0) return + const tasks = ids.map(async (id) => { + const user = users.find((u) => u.id === id) + if (!user) return + if (!canManageUser(user.role)) return + const response = await fetch(`/api/admin/users/${id}`, { method: "DELETE", credentials: "include" }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? `Falha ao remover ${user.email}`) + } + }) + await Promise.allSettled(tasks) + setUsers((prev) => prev.filter((u) => !ids.includes(u.id))) + } + + async function performBulkDeleteMachines(ids: string[]) { + if (ids.length === 0) return + const tasks = ids.map(async (id) => { + const user = users.find((u) => u.id === id) + if (!user) return + const machineId = extractMachineId(user.email) + if (!machineId) return + const response = await fetch(`/api/admin/machines/delete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ machineId }), + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? `Falha ao remover agente ${user.email}`) + } + }) + await Promise.allSettled(tasks) + setUsers((prev) => prev.filter((u) => !ids.includes(u.id))) + } + + async function performBulkRevokeInvites(ids: string[]) { + if (ids.length === 0) return + const tasks = ids.map(async (id) => { + const invite = invites.find((i) => i.id === id) + if (!invite) return + if (!canManageInvite(invite.role)) return + const response = await fetch(`/api/admin/invites/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason: "Revogado em massa" }), + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? `Falha ao revogar ${invite.email}`) + } + }) + await Promise.allSettled(tasks) + setInvites((prev) => prev.map((i) => (ids.includes(i.id) && i.status === "pending" ? { ...i, status: "revoked", revokedAt: new Date().toISOString() } : i))) + } + async function handleSaveUser(event: React.FormEvent) { event.preventDefault() if (!editUser) return @@ -718,20 +865,19 @@ async function handleDeleteUser() { return ( <> - + - Equipe + Equipe + Usuários Agentes de máquina Convites - +

Equipe cadastrada

-

- {filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "colaborador" : "colaboradores"} -

+

{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "membro" : "membros"}

) : null} +
@@ -785,6 +962,15 @@ async function handleDeleteUser() { + @@ -797,6 +983,22 @@ async function handleDeleteUser() { {filteredTeamUsers.map((user) => ( + @@ -834,7 +1036,7 @@ async function handleDeleteUser() { ))} {filteredTeamUsers.length === 0 ? ( -
+
+ toggleTeamSelectAll(!!value)} + aria-label="Selecionar todos" + /> +
+
Nome E-mail Papel
+
+ { + setTeamSelection((prev) => { + const next = new Set(prev) + if (checked) next.add(user.id) + else next.delete(user.id) + return next + }) + }} + aria-label="Selecionar linha" + /> +
+
{user.name || "—"} {user.email} {formatRole(user.role)}
+ {teamUsers.length === 0 ? "Nenhum usuário cadastrado até o momento." : "Nenhum usuário corresponde aos filtros atuais."} @@ -889,6 +1091,181 @@ async function handleDeleteUser() { + +
+
+

Usuários

+

{filteredPeopleUsers.length} {filteredPeopleUsers.length === 1 ? "usuário" : "usuários"}

+
+ +
+
+
+ + setPeopleSearch(event.target.value)} + placeholder="Buscar por nome, e-mail ou empresa..." + className="h-9 pl-9" + /> +
+
+ + + + {(peopleSearch.trim().length > 0 || peopleRoleFilter !== "all" || peopleCompanyFilter !== "all" || peopleTenantFilter !== "all") ? ( + + ) : null} + +
+
+ + + Usuários + Colaboradores e gestores vinculados às empresas. + + + + + + + + + + + + + + + + + {filteredPeopleUsers.map((user) => ( + + + + + + + + + + + ))} + {filteredPeopleUsers.length === 0 ? ( + + + + ) : null} + +
+
+ togglePeopleSelectAll(!!value)} + aria-label="Selecionar todos" + /> +
+
NomeE-mailPerfilEmpresaEspaçoCriado emAções
+
+ { + setPeopleSelection((prev) => { + const next = new Set(prev) + if (checked) next.add(user.id) + else next.delete(user.id) + return next + }) + }} + aria-label="Selecionar linha" + /> +
+
{user.name || "—"}{user.email}{formatRole(user.role)}{user.companyName ?? "—"}{formatTenantLabel(user.tenantId, defaultTenantId)}{formatDate(user.createdAt)} +
+ + +
+
+ {peopleUsers.length === 0 + ? "Nenhum usuário cadastrado até o momento." + : "Nenhum usuário corresponde aos filtros atuais."} +
+
+
+
+
@@ -932,6 +1309,15 @@ async function handleDeleteUser() { Limpar filtros ) : null} +
@@ -943,6 +1329,15 @@ async function handleDeleteUser() { + @@ -955,6 +1350,22 @@ async function handleDeleteUser() { const machineId = extractMachineId(user.email) return ( + -
+
+ 0 && selectedMachineUsers.length === filteredMachineUsers.length || (selectedMachineUsers.length > 0 && selectedMachineUsers.length < filteredMachineUsers.length && "indeterminate")} + onCheckedChange={(value) => toggleMachinesSelectAll(!!value)} + aria-label="Selecionar todos" + /> +
+
Identificação E-mail técnico Perfil
+
+ { + setMachineSelection((prev) => { + const next = new Set(prev) + if (checked) next.add(user.id) + else next.delete(user.id) + return next + }) + }} + aria-label="Selecionar linha" + /> +
+
{user.name || "Máquina"} {user.email} @@ -990,7 +1401,7 @@ async function handleDeleteUser() { })} {filteredMachineUsers.length === 0 ? (
+ {machineUsers.length === 0 ? "Nenhuma máquina provisionada ainda." : "Nenhum agente encontrado para a busca atual."} @@ -1106,6 +1517,15 @@ async function handleDeleteUser() { + @@ -1117,6 +1537,22 @@ async function handleDeleteUser() { {invites.map((invite) => ( + - ) : null}
+
+ toggleInvitesSelectAll(!!value)} + aria-label="Selecionar todos" + /> +
+
Colaborador Papel Espaço
+
+ { + setInviteSelection((prev) => { + const next = new Set(prev) + if (checked) next.add(invite.id) + else next.delete(invite.id) + return next + }) + }} + aria-label="Selecionar linha" + /> +
+
{invite.name || invite.email} @@ -1167,13 +1603,24 @@ async function handleDeleteUser() { ))} {invites.length === 0 ? (
+ Nenhum convite emitido até o momento.
+
+ +
@@ -1508,6 +1955,184 @@ async function handleDeleteUser() { + + {/* Bulk actions */} + + + + Remover membros selecionados + Revoga o acesso imediatamente. Registros históricos permanecem. + +
+ {selectedTeamUsers.slice(0, 5).map((u) => ( +
+ {u.name || u.email} — {u.email} +
+ ))} + {selectedTeamUsers.length > 5 ? ( +
+ {selectedTeamUsers.length - 5} outros
+ ) : null} +
+ + + + +
+
+ + + + + Remover usuários selecionados + Os usuários perderão o acesso imediatamente. + +
+ {selectedPeopleUsers.slice(0, 5).map((u) => ( +
+ {u.name || u.email} — {u.email} +
+ ))} + {selectedPeopleUsers.length > 5 ? ( +
+ {selectedPeopleUsers.length - 5} outros
+ ) : null} +
+ + + + +
+
+ + + + + Remover agentes selecionados + As máquinas serão desconectadas e precisarão de novo provisionamento. + +
+ {selectedMachineUsers.slice(0, 5).map((u) => ( +
+ {u.name || u.email} — {u.email} +
+ ))} + {selectedMachineUsers.length > 5 ? ( +
+ {selectedMachineUsers.length - 5} outros
+ ) : null} +
+ + + + +
+
+ + + + + Revogar convites selecionados + Os links serão invalidados imediatamente. + +
+ {selectedInvites.slice(0, 5).map((i) => ( +
+ {i.email} — {formatRole(i.role)} +
+ ))} + {selectedInvites.length > 5 ? ( +
+ {selectedInvites.length - 5} outros
+ ) : null} +
+ + + + +
+
) }