admin: split Equipe/Usuários, add bulk select/actions for users, machines and invites; add company/tenant filters

This commit is contained in:
Esdras Renan 2025-10-19 16:08:46 -03:00
parent 515d1718a6
commit a325d612cb

View file

@ -2,7 +2,7 @@
import Link from "next/link" import Link from "next/link"
import { useCallback, useEffect, useMemo, useState, useTransition } from "react" 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" 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -221,14 +222,39 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
}) })
return Array.from(unique) return Array.from(unique)
}, [normalizedRoles, viewerIsAdmin]) }, [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 machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
const defaultCreateRole: RoleOption = selectableRoles[0] ?? "agent" const defaultCreateRole: RoleOption = selectableRoles[0] ?? "agent"
// Equipe
const [teamSearch, setTeamSearch] = useState("") const [teamSearch, setTeamSearch] = useState("")
const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all") const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all")
const [teamCompanyFilter, setTeamCompanyFilter] = useState<string>("all")
const [teamTenantFilter, setTeamTenantFilter] = useState<string>("all")
const [teamSelection, setTeamSelection] = useState<Set<string>>(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<string>("all")
const [peopleTenantFilter, setPeopleTenantFilter] = useState<string>("all")
const [peopleSelection, setPeopleSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingPeople, setIsBulkDeletingPeople] = useState(false)
const [bulkDeletePeopleOpen, setBulkDeletePeopleOpen] = useState(false)
const [machineSearch, setMachineSearch] = useState("") const [machineSearch, setMachineSearch] = useState("")
const [machinePersonaFilter, setMachinePersonaFilter] = useState<"all" | "manager" | "collaborator" | "unassigned">("all") const [machinePersonaFilter, setMachinePersonaFilter] = useState<"all" | "manager" | "collaborator" | "unassigned">("all")
const [machineSelection, setMachineSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingMachines, setIsBulkDeletingMachines] = useState(false)
const [bulkDeleteMachinesOpen, setBulkDeleteMachinesOpen] = useState(false)
const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
name: "", name: "",
@ -240,9 +266,19 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [isCreatingUser, setIsCreatingUser] = useState(false) const [isCreatingUser, setIsCreatingUser] = useState(false)
const [createPassword, setCreatePassword] = useState<string | null>(null) const [createPassword, setCreatePassword] = useState<string | null>(null)
// Options of tenants present in dataset for filtering
const tenantOptions = useMemo(() => {
const set = new Set<string>()
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 filteredTeamUsers = useMemo(() => {
const term = teamSearch.trim().toLowerCase() const term = teamSearch.trim().toLowerCase()
return teamUsers.filter((user) => { 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 (teamRoleFilter !== "all" && coerceRole(user.role) !== teamRoleFilter) return false
if (!term) return true if (!term) return true
const haystack = [ const haystack = [
@ -255,7 +291,27 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
.toLowerCase() .toLowerCase()
return haystack.includes(term) 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 filteredMachineUsers = useMemo(() => {
const term = machineSearch.trim().toLowerCase() 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<Set<string>>(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<HTMLFormElement>) { async function handleSaveUser(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
if (!editUser) return if (!editUser) return
@ -718,20 +865,19 @@ async function handleDeleteUser() {
return ( return (
<> <>
<Tabs defaultValue="users" className="w-full"> <Tabs defaultValue="team" className="w-full">
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1"> <TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
<TabsTrigger value="users" className="rounded-lg">Equipe</TabsTrigger> <TabsTrigger value="team" className="rounded-lg">Equipe</TabsTrigger>
<TabsTrigger value="people" className="rounded-lg">Usuários</TabsTrigger>
<TabsTrigger value="machines" className="rounded-lg">Agentes de máquina</TabsTrigger> <TabsTrigger value="machines" className="rounded-lg">Agentes de máquina</TabsTrigger>
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger> <TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="users" className="mt-6 space-y-6"> <TabsContent value="team" className="mt-6 space-y-6">
<div className="flex flex-col gap-4 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-semibold text-neutral-900">Equipe cadastrada</p> <p className="text-sm font-semibold text-neutral-900">Equipe cadastrada</p>
<p className="text-xs text-neutral-500"> <p className="text-xs text-neutral-500">{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "membro" : "membros"}</p>
{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "colaborador" : "colaboradores"}
</p>
</div> </div>
<Button onClick={handleOpenCreateUser} size="sm" className="gap-2 self-start sm:self-auto"> <Button onClick={handleOpenCreateUser} size="sm" className="gap-2 self-start sm:self-auto">
<IconUserPlus className="size-4" /> <IconUserPlus className="size-4" />
@ -755,10 +901,30 @@ async function handleDeleteUser() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Todos os papéis</SelectItem> <SelectItem value="all">Todos os papéis</SelectItem>
{selectableRoles.map((option) => ( {selectableRoles.filter((option) => ["admin", "agent"].includes(option)).map((option) => (
<SelectItem key={`team-filter-${option}`} value={option}> <SelectItem key={`team-filter-${option}`} value={option}>{formatRole(option)}</SelectItem>
{formatRole(option)} ))}
</SelectItem> </SelectContent>
</Select>
<Select value={teamCompanyFilter} onValueChange={setTeamCompanyFilter}>
<SelectTrigger className="h-9 w-full sm:w-56">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as empresas</SelectItem>
{companies.map((company) => (
<SelectItem key={`team-company-${company.id}`} value={company.id}>{company.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={teamTenantFilter} onValueChange={setTeamTenantFilter}>
<SelectTrigger className="h-9 w-full sm:w-40">
<SelectValue placeholder="Espaço" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os espaços</SelectItem>
{tenantOptions.map((t) => (
<SelectItem key={`team-tenant-${t}`} value={t}>{formatTenantLabel(t, defaultTenantId)}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@ -769,11 +935,22 @@ async function handleDeleteUser() {
onClick={() => { onClick={() => {
setTeamSearch("") setTeamSearch("")
setTeamRoleFilter("all") setTeamRoleFilter("all")
setTeamCompanyFilter("all")
setTeamTenantFilter("all")
}} }}
> >
Limpar filtros Limpar filtros
</Button> </Button>
) : null} ) : null}
<Button
variant="outline"
size="sm"
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
disabled={selectedTeamUsers.length === 0 || isBulkDeletingTeam}
onClick={() => setBulkDeleteTeamOpen(true)}
>
<IconTrash className="size-4" /> Excluir selecionados
</Button>
</div> </div>
</div> </div>
<Card> <Card>
@ -785,6 +962,15 @@ async function handleDeleteUser() {
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm"> <table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead> <thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500"> <tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="w-10 py-3 pr-2 font-medium">
<div className="flex items-center justify-center">
<Checkbox
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
aria-label="Selecionar todos"
/>
</div>
</th>
<th className="py-3 pr-4 font-medium">Nome</th> <th className="py-3 pr-4 font-medium">Nome</th>
<th className="py-3 pr-4 font-medium">E-mail</th> <th className="py-3 pr-4 font-medium">E-mail</th>
<th className="py-3 pr-4 font-medium">Papel</th> <th className="py-3 pr-4 font-medium">Papel</th>
@ -797,6 +983,22 @@ async function handleDeleteUser() {
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{filteredTeamUsers.map((user) => ( {filteredTeamUsers.map((user) => (
<tr key={user.id} className="hover:bg-slate-50"> <tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-2">
<div className="flex items-center justify-center">
<Checkbox
checked={teamSelection.has(user.id)}
onCheckedChange={(checked) => {
setTeamSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(user.id)
else next.delete(user.id)
return next
})
}}
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td> <td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td> <td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td> <td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
@ -834,7 +1036,7 @@ async function handleDeleteUser() {
))} ))}
{filteredTeamUsers.length === 0 ? ( {filteredTeamUsers.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="py-6 text-center text-neutral-500"> <td colSpan={8} className="py-6 text-center text-neutral-500">
{teamUsers.length === 0 {teamUsers.length === 0
? "Nenhum usuário cadastrado até o momento." ? "Nenhum usuário cadastrado até o momento."
: "Nenhum usuário corresponde aos filtros atuais."} : "Nenhum usuário corresponde aos filtros atuais."}
@ -889,6 +1091,181 @@ async function handleDeleteUser() {
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="people" className="mt-6 space-y-6">
<div className="flex flex-col gap-4 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-neutral-900">Usuários</p>
<p className="text-xs text-neutral-500">{filteredPeopleUsers.length} {filteredPeopleUsers.length === 1 ? "usuário" : "usuários"}</p>
</div>
<Button onClick={handleOpenCreateUser} size="sm" className="gap-2 self-start sm:self-auto">
<IconUserPlus className="size-4" />
Novo usuário
</Button>
</div>
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-xs">
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={peopleSearch}
onChange={(event) => setPeopleSearch(event.target.value)}
placeholder="Buscar por nome, e-mail ou empresa..."
className="h-9 pl-9"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select value={peopleRoleFilter} onValueChange={(value) => setPeopleRoleFilter(value as "all" | "manager" | "collaborator")}>
<SelectTrigger className="h-9 w-full sm:w-48">
<SelectValue placeholder="Perfil" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os perfis</SelectItem>
<SelectItem value="manager">Gestores</SelectItem>
<SelectItem value="collaborator">Colaboradores</SelectItem>
</SelectContent>
</Select>
<Select value={peopleCompanyFilter} onValueChange={setPeopleCompanyFilter}>
<SelectTrigger className="h-9 w-full sm:w-56">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as empresas</SelectItem>
{companies.map((company) => (
<SelectItem key={`people-company-${company.id}`} value={company.id}>{company.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={peopleTenantFilter} onValueChange={setPeopleTenantFilter}>
<SelectTrigger className="h-9 w-full sm:w-40">
<SelectValue placeholder="Espaço" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os espaços</SelectItem>
{tenantOptions.map((t) => (
<SelectItem key={`people-tenant-${t}`} value={t}>{formatTenantLabel(t, defaultTenantId)}</SelectItem>
))}
</SelectContent>
</Select>
{(peopleSearch.trim().length > 0 || peopleRoleFilter !== "all" || peopleCompanyFilter !== "all" || peopleTenantFilter !== "all") ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setPeopleSearch("")
setPeopleRoleFilter("all")
setPeopleCompanyFilter("all")
setPeopleTenantFilter("all")
}}
>
Limpar filtros
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
disabled={selectedPeopleUsers.length === 0 || isBulkDeletingPeople}
onClick={() => setBulkDeletePeopleOpen(true)}
>
<IconTrash className="size-4" /> Excluir selecionados
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Usuários</CardTitle>
<CardDescription>Colaboradores e gestores vinculados às empresas.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="w-10 py-3 pr-2 font-medium">
<div className="flex items-center justify-center">
<Checkbox
checked={allPeopleSelected || (somePeopleSelected && "indeterminate")}
onCheckedChange={(value) => togglePeopleSelectAll(!!value)}
aria-label="Selecionar todos"
/>
</div>
</th>
<th className="py-3 pr-4 font-medium">Nome</th>
<th className="py-3 pr-4 font-medium">E-mail</th>
<th className="py-3 pr-4 font-medium">Perfil</th>
<th className="py-3 pr-4 font-medium">Empresa</th>
<th className="py-3 pr-4 font-medium">Espaço</th>
<th className="py-3 pr-4 font-medium">Criado em</th>
<th className="py-3 font-medium">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredPeopleUsers.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-2">
<div className="flex items-center justify-center">
<Checkbox
checked={peopleSelection.has(user.id)}
onCheckedChange={(checked) => {
setPeopleSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(user.id)
else next.delete(user.id)
return next
})
}}
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setEditUserId(user.id)
}}
>
Editar
</Button>
<Button
variant="outline"
size="sm"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setDeleteUserId(user.id)
}}
>
Remover
</Button>
</div>
</td>
</tr>
))}
{filteredPeopleUsers.length === 0 ? (
<tr>
<td colSpan={8} className="py-6 text-center text-neutral-500">
{peopleUsers.length === 0
? "Nenhum usuário cadastrado até o momento."
: "Nenhum usuário corresponde aos filtros atuais."}
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="machines" className="mt-6 space-y-6"> <TabsContent value="machines" className="mt-6 space-y-6">
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
@ -932,6 +1309,15 @@ async function handleDeleteUser() {
Limpar filtros Limpar filtros
</Button> </Button>
) : null} ) : null}
<Button
variant="outline"
size="sm"
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
disabled={selectedMachineUsers.length === 0 || isBulkDeletingMachines}
onClick={() => setBulkDeleteMachinesOpen(true)}
>
<IconTrash className="size-4" /> Remover selecionados
</Button>
</div> </div>
</div> </div>
<Card> <Card>
@ -943,6 +1329,15 @@ async function handleDeleteUser() {
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm"> <table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead> <thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500"> <tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="w-10 py-3 pr-2 font-medium">
<div className="flex items-center justify-center">
<Checkbox
checked={selectedMachineUsers.length > 0 && selectedMachineUsers.length === filteredMachineUsers.length || (selectedMachineUsers.length > 0 && selectedMachineUsers.length < filteredMachineUsers.length && "indeterminate")}
onCheckedChange={(value) => toggleMachinesSelectAll(!!value)}
aria-label="Selecionar todos"
/>
</div>
</th>
<th className="py-3 pr-4 font-medium">Identificação</th> <th className="py-3 pr-4 font-medium">Identificação</th>
<th className="py-3 pr-4 font-medium">E-mail técnico</th> <th className="py-3 pr-4 font-medium">E-mail técnico</th>
<th className="py-3 pr-4 font-medium">Perfil</th> <th className="py-3 pr-4 font-medium">Perfil</th>
@ -955,6 +1350,22 @@ async function handleDeleteUser() {
const machineId = extractMachineId(user.email) const machineId = extractMachineId(user.email)
return ( return (
<tr key={user.id} className="hover:bg-slate-50"> <tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-2">
<div className="flex items-center justify-center">
<Checkbox
checked={machineSelection.has(user.id)}
onCheckedChange={(checked) => {
setMachineSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(user.id)
else next.delete(user.id)
return next
})
}}
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td> <td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td> <td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600"> <td className="py-3 pr-4 text-neutral-600">
@ -990,7 +1401,7 @@ async function handleDeleteUser() {
})} })}
{filteredMachineUsers.length === 0 ? ( {filteredMachineUsers.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="py-6 text-center text-neutral-500"> <td colSpan={6} className="py-6 text-center text-neutral-500">
{machineUsers.length === 0 {machineUsers.length === 0
? "Nenhuma máquina provisionada ainda." ? "Nenhuma máquina provisionada ainda."
: "Nenhum agente encontrado para a busca atual."} : "Nenhum agente encontrado para a busca atual."}
@ -1106,6 +1517,15 @@ async function handleDeleteUser() {
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm"> <table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead> <thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500"> <tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="w-10 py-3 pr-2 font-medium">
<div className="flex items-center justify-center">
<Checkbox
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
onCheckedChange={(value) => toggleInvitesSelectAll(!!value)}
aria-label="Selecionar todos"
/>
</div>
</th>
<th className="py-3 pr-4 font-medium">Colaborador</th> <th className="py-3 pr-4 font-medium">Colaborador</th>
<th className="py-3 pr-4 font-medium">Papel</th> <th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Espaço</th> <th className="py-3 pr-4 font-medium">Espaço</th>
@ -1117,6 +1537,22 @@ async function handleDeleteUser() {
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{invites.map((invite) => ( {invites.map((invite) => (
<tr key={invite.id} className="hover:bg-slate-50"> <tr key={invite.id} className="hover:bg-slate-50">
<td className="py-3 pr-2">
<div className="flex items-center justify-center">
<Checkbox
checked={inviteSelection.has(invite.id)}
onCheckedChange={(checked) => {
setInviteSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(invite.id)
else next.delete(invite.id)
return next
})
}}
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="py-3 pr-4"> <td className="py-3 pr-4">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span> <span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
@ -1167,13 +1603,24 @@ async function handleDeleteUser() {
))} ))}
{invites.length === 0 ? ( {invites.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="py-6 text-center text-neutral-500"> <td colSpan={7} className="py-6 text-center text-neutral-500">
Nenhum convite emitido até o momento. Nenhum convite emitido até o momento.
</td> </td>
</tr> </tr>
) : null} ) : null}
</tbody> </tbody>
</table> </table>
<div className="mt-3 flex items-center justify-end">
<Button
variant="outline"
size="sm"
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
disabled={selectedInvites.length === 0 || isBulkRevokingInvites}
onClick={() => setBulkRevokeInvitesOpen(true)}
>
<IconTrash className="size-4" /> Revogar selecionados
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@ -1508,6 +1955,184 @@ async function handleDeleteUser() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Bulk actions */}
<Dialog open={bulkDeleteTeamOpen} onOpenChange={setBulkDeleteTeamOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover membros selecionados</DialogTitle>
<DialogDescription>Revoga o acesso imediatamente. Registros históricos permanecem.</DialogDescription>
</DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto">
{selectedTeamUsers.slice(0, 5).map((u) => (
<div key={`team-del-${u.id}`} className="rounded-md bg-slate-100 px-3 py-2 text-sm">
{u.name || u.email} <span className="text-neutral-500"> {u.email}</span>
</div>
))}
{selectedTeamUsers.length > 5 ? (
<div className="px-3 text-xs text-neutral-500">+ {selectedTeamUsers.length - 5} outros</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteTeamOpen(false)} disabled={isBulkDeletingTeam}>
Cancelar
</Button>
<Button
variant="destructive"
disabled={isBulkDeletingTeam}
onClick={async () => {
setIsBulkDeletingTeam(true)
try {
await performBulkDeleteUsers(selectedTeamUsers.map((u) => u.id))
setTeamSelection(new Set())
setBulkDeleteTeamOpen(false)
toast.success("Remoção concluída")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao excluir selecionados"
toast.error(message)
} finally {
setIsBulkDeletingTeam(false)
}
}}
>
Excluir selecionados
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={bulkDeletePeopleOpen} onOpenChange={setBulkDeletePeopleOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover usuários selecionados</DialogTitle>
<DialogDescription>Os usuários perderão o acesso imediatamente.</DialogDescription>
</DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto">
{selectedPeopleUsers.slice(0, 5).map((u) => (
<div key={`people-del-${u.id}`} className="rounded-md bg-slate-100 px-3 py-2 text-sm">
{u.name || u.email} <span className="text-neutral-500"> {u.email}</span>
</div>
))}
{selectedPeopleUsers.length > 5 ? (
<div className="px-3 text-xs text-neutral-500">+ {selectedPeopleUsers.length - 5} outros</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDeletePeopleOpen(false)} disabled={isBulkDeletingPeople}>
Cancelar
</Button>
<Button
variant="destructive"
disabled={isBulkDeletingPeople}
onClick={async () => {
setIsBulkDeletingPeople(true)
try {
await performBulkDeleteUsers(selectedPeopleUsers.map((u) => u.id))
setPeopleSelection(new Set())
setBulkDeletePeopleOpen(false)
toast.success("Remoção concluída")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao excluir selecionados"
toast.error(message)
} finally {
setIsBulkDeletingPeople(false)
}
}}
>
Excluir selecionados
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={bulkDeleteMachinesOpen} onOpenChange={setBulkDeleteMachinesOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover agentes selecionados</DialogTitle>
<DialogDescription>As máquinas serão desconectadas e precisarão de novo provisionamento.</DialogDescription>
</DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto">
{selectedMachineUsers.slice(0, 5).map((u) => (
<div key={`machine-del-${u.id}`} className="rounded-md bg-slate-100 px-3 py-2 text-sm">
{u.name || u.email} <span className="text-neutral-500"> {u.email}</span>
</div>
))}
{selectedMachineUsers.length > 5 ? (
<div className="px-3 text-xs text-neutral-500">+ {selectedMachineUsers.length - 5} outros</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteMachinesOpen(false)} disabled={isBulkDeletingMachines}>
Cancelar
</Button>
<Button
variant="destructive"
disabled={isBulkDeletingMachines}
onClick={async () => {
setIsBulkDeletingMachines(true)
try {
await performBulkDeleteMachines(selectedMachineUsers.map((u) => u.id))
setMachineSelection(new Set())
setBulkDeleteMachinesOpen(false)
toast.success("Agentes removidos")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao excluir selecionados"
toast.error(message)
} finally {
setIsBulkDeletingMachines(false)
}
}}
>
Remover selecionados
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={bulkRevokeInvitesOpen} onOpenChange={setBulkRevokeInvitesOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Revogar convites selecionados</DialogTitle>
<DialogDescription>Os links serão invalidados imediatamente.</DialogDescription>
</DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto">
{selectedInvites.slice(0, 5).map((i) => (
<div key={`invite-revoke-${i.id}`} className="rounded-md bg-slate-100 px-3 py-2 text-sm">
{i.email} <span className="text-neutral-500"> {formatRole(i.role)}</span>
</div>
))}
{selectedInvites.length > 5 ? (
<div className="px-3 text-xs text-neutral-500">+ {selectedInvites.length - 5} outros</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkRevokeInvitesOpen(false)} disabled={isBulkRevokingInvites}>
Cancelar
</Button>
<Button
variant="destructive"
disabled={isBulkRevokingInvites}
onClick={async () => {
setIsBulkRevokingInvites(true)
try {
const ids = selectedInvites.filter((i) => i.status === "pending").map((i) => i.id)
await performBulkRevokeInvites(ids)
setInviteSelection(new Set())
setBulkRevokeInvitesOpen(false)
toast.success("Convites revogados")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao revogar convites"
toast.error(message)
} finally {
setIsBulkRevokingInvites(false)
}
}}
>
Revogar selecionados
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
) )
} }