sistema-de-chamados/src/components/admin/admin-users-manager.tsx

2054 lines
87 KiB
TypeScript

"use client"
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
import { IconSearch, IconUserPlus, IconTrash } from "@tabler/icons-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
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,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies"
type AdminRole = RoleOption | "machine"
const NO_COMPANY_ID = "__none__"
type AdminUser = {
id: string
email: string
name: string
role: AdminRole
tenantId: string
createdAt: string
updatedAt: string | null
companyId: string | null
companyName: string | null
machinePersona: string | null
}
type AdminInvite = {
id: string
email: string
name: string | null
role: RoleOption
tenantId: string
status: "pending" | "accepted" | "revoked" | "expired"
token: string
inviteUrl: string
expiresAt: string
createdAt: string
createdById: string | null
acceptedAt: string | null
acceptedById: string | null
revokedAt: string | null
revokedById: string | null
revokedReason: string | null
}
type CompanyOption = {
id: string
name: string
}
type Props = {
initialUsers: AdminUser[]
initialInvites: AdminInvite[]
roleOptions: readonly AdminRole[]
defaultTenantId: string
viewerRole: string
}
const ROLE_LABELS: Record<string, string> = {
admin: "Administrador",
manager: "Gestor",
agent: "Agente",
collaborator: "Colaborador",
machine: "Agente de máquina",
}
function formatRole(role: string) {
const key = role?.toLowerCase?.() ?? ""
return ROLE_LABELS[key] ?? role
}
function formatMachinePersona(persona: string | null | undefined) {
const normalized = persona?.toLowerCase?.() ?? ""
if (normalized === "manager") return "Gestor"
if (normalized === "collaborator") return "Colaborador"
return "Sem persona"
}
function machinePersonaBadgeVariant(persona: string | null | undefined) {
const normalized = persona?.toLowerCase?.() ?? ""
if (normalized === "manager") return "secondary" as const
if (normalized === "collaborator") return "outline" as const
return "outline" as const
}
function formatTenantLabel(tenantId: string, defaultTenantId: string) {
if (!tenantId) return "Principal"
if (tenantId === defaultTenantId) return "Principal"
return tenantId
}
function formatDate(dateIso: string) {
const date = new Date(dateIso)
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "medium",
timeStyle: "short",
}).format(date)
}
function formatStatus(status: AdminInvite["status"]) {
switch (status) {
case "pending":
return "Pendente"
case "accepted":
return "Aceito"
case "revoked":
return "Revogado"
case "expired":
return "Expirado"
default:
return status
}
}
function inviteStatusVariant(status: AdminInvite["status"]) {
switch (status) {
case "pending":
return "secondary" as const
case "accepted":
return "default" as const
case "revoked":
return "destructive" as const
default:
return "outline" as const
}
}
function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite {
const { events: unusedEvents, ...rest } = invite
void unusedEvents
return rest
}
function coerceRole(role: AdminRole | string | null | undefined): RoleOption {
const candidate = (role ?? "agent").toLowerCase()
return (ROLE_OPTIONS as readonly string[]).includes(candidate) ? (candidate as RoleOption) : "agent"
}
function extractMachineId(email: string): string | null {
const match = /^machine-(.+)@machines\.local$/i.exec(email.trim())
return match ? match[1] : null
}
function isRestrictedRole(role?: string | null) {
const normalized = (role ?? "").toLowerCase()
return normalized === "admin" || normalized === "agent"
}
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId, viewerRole }: Props) {
const { convexUserId } = useAuth()
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
const [companies, setCompanies] = useState<CompanyOption[]>([])
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [role, setRole] = useState<RoleOption>("agent")
const [tenantId, setTenantId] = useState(defaultTenantId)
const [expiresInDays, setExpiresInDays] = useState("7")
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [reactivatingId, setReactivatingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const [linkEmail, setLinkEmail] = useState("")
const [linkCompanyId, setLinkCompanyId] = useState("")
const [assigningCompany, setAssigningCompany] = useState(false)
const [editUserId, setEditUserId] = useState<string | null>(null)
const editUser = useMemo(() => users.find((user) => user.id === editUserId) ?? null, [users, editUserId])
const [editForm, setEditForm] = useState({
name: "",
email: "",
role: "agent" as RoleOption,
tenantId: defaultTenantId,
companyId: "",
})
const [isSavingUser, setIsSavingUser] = useState(false)
const [isResettingPassword, setIsResettingPassword] = useState(false)
const [passwordPreview, setPasswordPreview] = useState<string | null>(null)
const [deleteUserId, setDeleteUserId] = useState<string | null>(null)
const deleteTarget = useMemo(
() => users.find((candidate) => candidate.id === deleteUserId) ?? null,
[users, deleteUserId]
)
const [isDeletingUser, setIsDeletingUser] = useState(false)
const [revokeDialogInviteId, setRevokeDialogInviteId] = useState<string | null>(null)
const revokeCandidate = useMemo(
() => invites.find((invite) => invite.id === revokeDialogInviteId) ?? null,
[invites, revokeDialogInviteId]
)
const viewerRoleNormalized = viewerRole?.toLowerCase?.() ?? "agent"
const viewerIsAdmin = viewerRoleNormalized === "admin"
const canManageUser = useCallback((role?: string | null) => viewerIsAdmin || !isRestrictedRole(role), [viewerIsAdmin])
const canManageInvite = useCallback((role: RoleOption) => viewerIsAdmin || !["admin", "agent"].includes(role), [viewerIsAdmin])
const normalizedRoles = useMemo<readonly AdminRole[]>(() => {
return (roleOptions && roleOptions.length > 0 ? roleOptions : ROLE_OPTIONS) as readonly AdminRole[]
}, [roleOptions])
const selectableRoles = useMemo(() => {
const unique = new Set<RoleOption>()
normalizedRoles.forEach((roleOption) => {
const coerced = coerceRole(roleOption)
if (!viewerIsAdmin && isRestrictedRole(coerced)) return
unique.add(coerced)
})
return Array.from(unique)
}, [normalizedRoles, viewerIsAdmin])
// 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<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)
// Removidos filtros antigos de Pessoas/Máquinas (agora unificado)
// Unificado (pessoas + máquinas)
const [usersSearch, setUsersSearch] = useState("")
const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people")
const [usersCompanyFilter, setUsersCompanyFilter] = useState<string>("all")
const [usersTenantFilter, setUsersTenantFilter] = useState<string>("all")
const [usersSelection, setUsersSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingUsersCombined, setIsBulkDeletingUsersCombined] = useState(false)
const [bulkDeleteUsersCombinedOpen, setBulkDeleteUsersCombinedOpen] = useState(false)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [createForm, setCreateForm] = useState({
name: "",
email: "",
role: defaultCreateRole,
tenantId: defaultTenantId,
companyId: NO_COMPANY_ID,
})
const [isCreatingUser, setIsCreatingUser] = useState(false)
const [createPassword, setCreatePassword] = useState<string | null>(null)
// Máquinas (para listar vínculos por usuário)
type MachinesListItem = {
id: string
hostname?: string
assignedUserEmail?: string | null
metadata?: unknown
linkedUsers?: Array<{ id: string; email: string; name: string }>
}
const machinesList = useQuery(
convexUserId ? api.machines.listByTenant : "skip",
convexUserId ? { tenantId: defaultTenantId, includeMetadata: true } : ("skip" as const)
) as MachinesListItem[] | undefined
const machinesByUserEmail = useMemo(() => {
const map = new Map<string, Array<{ id: string; hostname?: string }>>()
;(machinesList ?? []).forEach((m) => {
const push = (email?: string | null) => {
const e = (email ?? '').toLowerCase()
if (!e) return
const arr = map.get(e) ?? []
arr.push({ id: m.id, hostname: m.hostname })
map.set(e, arr)
}
push(m.assignedUserEmail)
// metadata collaborator
if (m.metadata && typeof m.metadata === 'object') {
const rec = m.metadata as Record<string, unknown>
const c = rec['collaborator']
if (c && typeof c === 'object') {
const base = c as Record<string, unknown>
if (typeof base.email === 'string') push(base.email)
}
}
// linked users
if (Array.isArray(m.linkedUsers)) {
m.linkedUsers.forEach((lu) => push(lu.email))
}
})
return map
}, [machinesList])
// 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 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 = [
user.name ?? "",
user.email ?? "",
user.companyName ?? "",
formatRole(user.role),
]
.join(" ")
.toLowerCase()
return haystack.includes(term)
})
}, [teamUsers, teamSearch, teamRoleFilter, teamCompanyFilter, teamTenantFilter, defaultTenantId])
// Removido: lista específica de Pessoas (uso substituído pelo unificado)
// Removido: filtro específico de agentes (uso substituído pelo unificado)
const combinedBaseUsers = useMemo(() => {
if (usersTypeFilter === "people") return peopleUsers
if (usersTypeFilter === "machines") return machineUsers
return [...peopleUsers, ...machineUsers]
}, [peopleUsers, machineUsers, usersTypeFilter])
const filteredCombinedUsers = useMemo(() => {
const term = usersSearch.trim().toLowerCase()
return combinedBaseUsers.filter((user) => {
if (usersCompanyFilter !== "all" && user.companyId !== usersCompanyFilter) return false
if (usersTenantFilter !== "all" && (user.tenantId ?? defaultTenantId) !== usersTenantFilter) return false
if (!term) return true
const persona = (user.machinePersona ?? "").toLowerCase()
const machineId = extractMachineId(user.email) ?? ""
const haystack = [
user.name ?? "",
user.email ?? "",
user.companyName ?? "",
formatRole(user.role),
persona,
machineId,
]
.join(" ")
.toLowerCase()
return haystack.includes(term)
})
}, [combinedBaseUsers, usersSearch, usersCompanyFilter, usersTenantFilter, defaultTenantId])
useEffect(() => {
void (async () => {
try {
const response = await fetch("/api/admin/companies", { credentials: "include" })
const json = (await response.json()) as { companies?: CompanyOption[] }
const mapped = (json.companies ?? []).map((company) => ({ id: company.id, name: company.name }))
setCompanies(mapped)
if (mapped.length > 0 && !linkCompanyId) {
setLinkCompanyId(mapped[0].id)
}
} catch (error) {
console.error("Falha ao carregar empresas", error)
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!editUser) {
setEditForm({ name: "", email: "", role: "agent", tenantId: defaultTenantId, companyId: "" })
setPasswordPreview(null)
return
}
setEditForm({
name: editUser.name || "",
email: editUser.email,
role: coerceRole(editUser.role),
tenantId: editUser.tenantId || defaultTenantId,
companyId: editUser.companyId ?? "",
})
setPasswordPreview(null)
}, [editUser, defaultTenantId])
const linkedMachinesForEditUser = useMemo(() => {
if (!editUser || !machinesList) return [] as Array<{ id: string; hostname?: string }>
const email = (editUser.email ?? "").toLowerCase()
const results: Array<{ id: string; hostname?: string }> = []
machinesList.forEach((m) => {
const assigned = (m.assignedUserEmail ?? "").toLowerCase()
let collaboratorEmail = ""
if (m.metadata && typeof m.metadata === "object") {
const rec = m.metadata as Record<string, unknown>
const c = rec["collaborator"]
if (c && typeof c === "object") {
const base = c as Record<string, unknown>
if (typeof base.email === "string") collaboratorEmail = base.email.toLowerCase()
}
}
const linked = Array.isArray(m.linkedUsers)
? m.linkedUsers.some((lu) => (lu.email ?? '').toLowerCase() === email)
: false
if (assigned === email || (collaboratorEmail && collaboratorEmail === email) || linked) {
results.push({ id: m.id, hostname: m.hostname })
}
})
return results
}, [editUser, machinesList])
useEffect(() => {
setCreateForm((prev) => ({
...prev,
role: defaultCreateRole,
tenantId: defaultTenantId,
}))
}, [defaultCreateRole, defaultTenantId])
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedEmail = email.trim().toLowerCase()
if (!normalizedEmail || !normalizedEmail.includes("@")) {
toast.error("Informe um e-mail válido")
return
}
const payload = {
email: normalizedEmail,
name: name.trim(),
role,
tenantId,
expiresInDays: Number.parseInt(expiresInDays, 10),
}
startTransition(async () => {
try {
const response = await fetch("/api/admin/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível gerar o convite")
}
const data = (await response.json()) as { invite: AdminInvite }
const nextInvite = sanitizeInvite(data.invite)
setInvites((previous) => [nextInvite, ...previous.filter((item) => item.id !== nextInvite.id)])
setEmail("")
setName("")
setRole("agent")
setTenantId(defaultTenantId)
setExpiresInDays("7")
setLastInviteLink(nextInvite.inviteUrl)
toast.success("Convite criado com sucesso")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao criar convite"
toast.error(message)
}
})
}
function handleCopy(link: string) {
navigator.clipboard
.writeText(link)
.then(() => toast.success("Link de convite copiado"))
.catch(() => toast.error("Não foi possível copiar o link"))
}
async function handleRevokeConfirmed() {
if (!revokeCandidate || revokeCandidate.status !== "pending") {
setRevokeDialogInviteId(null)
return
}
if (!canManageInvite(revokeCandidate.role)) {
toast.error("Você não pode revogar convites deste papel")
setRevokeDialogInviteId(null)
return
}
setRevokingId(revokeCandidate.id)
try {
const response = await fetch(`/api/admin/invites/${revokeCandidate.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "Revogado manualmente" }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao revogar convite")
}
const data = (await response.json()) as { invite: AdminInvite }
const updated = sanitizeInvite(data.invite)
setInvites((previous) => previous.map((item) => (item.id === updated.id ? updated : item)))
toast.success("Convite revogado")
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao revogar convite"
toast.error(message)
} finally {
setRevokingId(null)
setRevokeDialogInviteId(null)
}
}
async function handleReactivate(invite: AdminInvite) {
if (!canReactivateInvitePolicy(invite)) return
if (!canManageInvite(invite.role)) {
toast.error("Você não pode reativar convites deste papel")
return
}
setReactivatingId(invite.id)
try {
const response = await fetch(`/api/admin/invites/${invite.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "reactivate" }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao reativar convite")
}
const data = (await response.json()) as { invite: AdminInvite }
const normalized = sanitizeInvite(data.invite)
setInvites((previous) => previous.map((item) => (item.id === normalized.id ? normalized : item)))
toast.success("Convite reativado")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível reativar"
toast.error(message)
} finally {
setReactivatingId(null)
}
}
async function handleAssignCompany(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedEmail = linkEmail.trim().toLowerCase()
if (!normalizedEmail || !normalizedEmail.includes("@")) {
toast.error("Informe um e-mail válido para vincular")
return
}
if (!linkCompanyId) {
toast.error("Selecione a empresa para vincular")
return
}
setAssigningCompany(true)
toast.loading("Vinculando colaborador...", { id: "assign-company" })
try {
const response = await fetch("/api/admin/users/assign-company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: normalizedEmail, companyId: linkCompanyId }),
credentials: "include",
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao vincular")
}
toast.success("Colaborador vinculado com sucesso", { id: "assign-company" })
setLinkEmail("")
setLinkCompanyId("")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível vincular"
toast.error(message, { id: "assign-company" })
} finally {
setAssigningCompany(false)
}
}
const resetCreateForm = useCallback(() => {
setCreateForm({
name: "",
email: "",
role: defaultCreateRole,
tenantId: defaultTenantId,
companyId: NO_COMPANY_ID,
})
setCreatePassword(null)
}, [defaultCreateRole, defaultTenantId])
const handleOpenCreateUser = useCallback(() => {
resetCreateForm()
setCreateDialogOpen(true)
}, [resetCreateForm])
const handleCloseCreateDialog = useCallback(() => {
resetCreateForm()
setCreateDialogOpen(false)
}, [resetCreateForm])
async function handleCreateUser(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const payload = {
name: createForm.name.trim(),
email: createForm.email.trim().toLowerCase(),
role: createForm.role,
tenantId: createForm.tenantId.trim() || defaultTenantId,
}
if (!payload.name) {
toast.error("Informe o nome do usuário")
return
}
if (!payload.email || !payload.email.includes("@")) {
toast.error("Informe um e-mail válido")
return
}
setIsCreatingUser(true)
setCreatePassword(null)
try {
const response = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível criar o usuário")
}
const data = (await response.json()) as { user: { id: string; email: string; name: string | null; role: string; tenantId: string | null; createdAt: string }; temporaryPassword: string }
const normalizedRole = coerceRole(data.user.role)
const normalizedUser: AdminUser = {
id: data.user.id,
email: data.user.email,
name: data.user.name ?? data.user.email,
role: normalizedRole,
tenantId: data.user.tenantId ?? defaultTenantId,
createdAt: data.user.createdAt,
updatedAt: null,
companyId: null,
companyName: null,
machinePersona: null,
}
if (createForm.companyId && createForm.companyId !== NO_COMPANY_ID) {
try {
const assignResponse = await fetch("/api/admin/users/assign-company", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: normalizedUser.email, companyId: createForm.companyId }),
})
if (assignResponse.ok) {
const company = companies.find((company) => company.id === createForm.companyId)
normalizedUser.companyId = createForm.companyId
normalizedUser.companyName = company?.name ?? null
} else {
const assignData = await assignResponse.json().catch(() => ({}))
toast.error(assignData.error ?? "Não foi possível vincular a empresa")
}
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao vincular empresa"
toast.error(message)
}
}
setUsers((previous) => [normalizedUser, ...previous])
setCreatePassword(data.temporaryPassword)
toast.success("Usuário criado com sucesso")
setCreateForm((prev) => ({
...prev,
name: "",
email: "",
companyId: NO_COMPANY_ID,
}))
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao criar usuário"
toast.error(message)
} finally {
setIsCreatingUser(false)
}
}
// 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
// Removido: seleção específica de Pessoas (uso substituído pelo unificado)
// Removido: seleção específica de Máquinas (uso substituído pelo unificado)
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())
}
// Removidos: toggles de seleção específicos (uso substituído pelo unificado)
function toggleInvitesSelectAll(checked: boolean) {
setInviteSelection(checked ? new Set(invites.map((i) => i.id)) : new Set())
}
function toggleUsersCombinedSelectAll(checked: boolean) {
setUsersSelection(checked ? new Set(filteredCombinedUsers.map((u) => u.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 performBulkDeleteUsersCombined(ids: string[]) {
if (ids.length === 0) return
const machineIds: string[] = []
const humanIds: string[] = []
ids.forEach((id) => {
const u = users.find((x) => x.id === id)
if (!u) return
if (u.role === "machine") machineIds.push(id)
else humanIds.push(id)
})
await Promise.allSettled([
performBulkDeleteMachines(machineIds),
performBulkDeleteUsers(humanIds),
])
}
async function handleSaveUser(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!editUser) return
const payload = {
name: editForm.name.trim(),
email: editForm.email.trim().toLowerCase(),
role: editForm.role,
tenantId: editForm.tenantId.trim() || defaultTenantId,
companyId: editForm.companyId || null,
}
if (!payload.name) {
toast.error("Informe o nome do usuário")
return
}
if (!payload.email || !payload.email.includes("@")) {
toast.error("Informe um e-mail válido")
return
}
setIsSavingUser(true)
try {
const response = await fetch(`/api/admin/users/${editUser.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível atualizar o usuário")
}
const data = (await response.json()) as { user: AdminUser }
setUsers((previous) => previous.map((item) => (item.id === data.user.id ? data.user : item)))
toast.success("Usuário atualizado com sucesso")
setEditUserId(null)
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar alterações"
toast.error(message)
} finally {
setIsSavingUser(false)
}
}
async function handleResetPassword() {
if (!editUser) return
if (!canManageUser(editUser.role)) {
toast.error("Você não pode gerar senha para este usuário")
return
}
setIsResettingPassword(true)
toast.loading("Gerando nova senha...", { id: "reset-password" })
try {
const response = await fetch(`/api/admin/users/${editUser.id}/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao gerar nova senha")
}
const data = (await response.json()) as { temporaryPassword: string }
setPasswordPreview(data.temporaryPassword)
toast.success("Senha temporária criada", { id: "reset-password" })
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao gerar senha"
toast.error(message, { id: "reset-password" })
} finally {
setIsResettingPassword(false)
}
}
const isMachineEditing = editUser?.role === "machine"
const editingRestricted = editUser ? !canManageUser(editUser.role) : false
const companyOptions = useMemo(
() => [{ id: NO_COMPANY_ID, name: "Sem empresa vinculada" }, ...companies],
[companies]
)
async function handleDeleteUser() {
if (!deleteTarget) return
if (!canManageUser(deleteTarget.role)) {
toast.error("Você não pode remover esse usuário")
setDeleteUserId(null)
return
}
setIsDeletingUser(true)
const isMachine = deleteTarget.role === "machine"
try {
if (isMachine) {
const machineId = extractMachineId(deleteTarget.email)
if (!machineId) {
throw new Error("Não foi possível identificar a máquina associada.")
}
const response = await fetch("/api/admin/machines/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId }),
credentials: "include",
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao remover agente de máquina")
}
toast.success("Agente de máquina removido")
} else {
const response = await fetch(`/api/admin/users/${deleteTarget.id}`, {
method: "DELETE",
credentials: "include",
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao remover colaborador")
}
toast.success("Colaborador removido")
}
setUsers((previous) => previous.filter((user) => user.id !== deleteTarget.id))
if (editUserId === deleteTarget.id) {
setEditUserId(null)
}
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível remover o usuário"
toast.error(message)
} finally {
setIsDeletingUser(false)
setDeleteUserId(null)
}
}
return (
<>
<Tabs defaultValue="team" className="w-full">
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
<TabsTrigger value="team" className="rounded-lg">Equipe</TabsTrigger>
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
</TabsList>
<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="space-y-1">
<p className="text-sm font-semibold text-neutral-900">Equipe cadastrada</p>
<p className="text-xs text-neutral-500">{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "membro" : "membros"}</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="rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:grid md:grid-cols-[minmax(0,1fr)_auto_auto_auto_auto] md:items-center md:gap-3">
<div className="relative w-full md:max-w-sm">
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={teamSearch}
onChange={(event) => setTeamSearch(event.target.value)}
placeholder="Buscar por nome, e-mail ou empresa..."
className="h-9 pl-9"
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
<Select value={teamRoleFilter} onValueChange={(value) => setTeamRoleFilter(value as "all" | RoleOption)}>
<SelectTrigger className="h-9 w-full sm:w-48">
<SelectValue placeholder="Todos os papéis" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os papéis</SelectItem>
{selectableRoles.filter((option) => ["admin", "agent"].includes(option)).map((option) => (
<SelectItem key={`team-filter-${option}`} value={option}>{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>
</Select>
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setTeamSearch("")
setTeamRoleFilter("all")
setTeamCompanyFilter("all")
setTeamTenantFilter("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={selectedTeamUsers.length === 0 || isBulkDeletingTeam}
onClick={() => setBulkDeleteTeamOpen(true)}
>
<IconTrash className="size-4" /> Excluir selecionados
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Equipe cadastrada</CardTitle>
<CardDescription>Usuários com acesso ao sistema. Edite papéis, dados pessoais ou gere uma nova senha.</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={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">E-mail</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Empresa</th>
<th className="py-3 pr-4 font-medium">Máquinas</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">
{filteredTeamUsers.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={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 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">
{(() => {
const list = machinesByUserEmail.get((user.email ?? '').toLowerCase()) ?? []
return list.length > 0 ? (
<span className="text-xs font-medium">{list.length} {list.length === 1 ? 'máquina' : 'máquinas'}</span>
) : '—'
})()}
</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>
))}
{filteredTeamUsers.length === 0 ? (
<tr>
<td colSpan={8} className="py-6 text-center text-neutral-500">
{teamUsers.length === 0
? "Nenhum usuário cadastrado até o momento."
: "Nenhum usuário corresponde aos filtros atuais."}
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Vincular usuário a empresa</CardTitle>
<CardDescription>Associe colaboradores existentes a uma empresa para liberar painéis de gestores e filtros específicos.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleAssignCompany} className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto]">
<div className="grid gap-2">
<Label>E-mail do usuário</Label>
<Input
value={linkEmail}
onChange={(event) => setLinkEmail(event.target.value)}
placeholder="colaborador@empresa.com"
type="email"
required
/>
</div>
<div className="grid gap-2">
<Label>Empresa</Label>
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
<SelectTrigger>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" disabled={assigningCompany || companies.length === 0}>
{assigningCompany ? "Vinculando..." : "Vincular"}
</Button>
</div>
</form>
<p className="mt-2 text-xs text-neutral-500">Caso a empresa ainda não exista, cadastre-a em <Link href="/admin/companies" className="underline underline-offset-4">Admin Empresas &amp; clientes</Link>.</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="users" 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">{filteredCombinedUsers.length} {filteredCombinedUsers.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="rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:grid md:grid-cols-[minmax(0,1fr)_auto_auto_auto_auto] md:items-center md:gap-3">
<div className="relative w-full md:max-w-sm">
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={usersSearch}
onChange={(event) => setUsersSearch(event.target.value)}
placeholder="Buscar por nome, e-mail, empresa ou máquina..."
className="h-9 pl-9"
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
<Select value={usersTypeFilter} onValueChange={(v) => setUsersTypeFilter(v as typeof usersTypeFilter)}>
<SelectTrigger className="h-9 w-full sm:w-40">
<SelectValue placeholder="Tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="people">Pessoas</SelectItem>
<SelectItem value="machines">Máquinas</SelectItem>
</SelectContent>
</Select>
<Select value={usersCompanyFilter} onValueChange={setUsersCompanyFilter}>
<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={`users-company-${company.id}`} value={company.id}>{company.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={usersTenantFilter} onValueChange={setUsersTenantFilter}>
<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={`users-tenant-${t}`} value={t}>{formatTenantLabel(t, defaultTenantId)}</SelectItem>
))}
</SelectContent>
</Select>
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all" || usersTenantFilter !== "all") ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setUsersSearch("")
setUsersTypeFilter("all")
setUsersCompanyFilter("all")
setUsersTenantFilter("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={usersSelection.size === 0 || isBulkDeletingUsersCombined}
onClick={() => setBulkDeleteUsersCombinedOpen(true)}
>
<IconTrash className="size-4" /> Excluir selecionados
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Usuários</CardTitle>
<CardDescription>Pessoas e máquinas com acesso ao sistema.</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={usersSelection.size > 0 && usersSelection.size === filteredCombinedUsers.length || (usersSelection.size > 0 && usersSelection.size < filteredCombinedUsers.length && "indeterminate")}
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!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">Tipo</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">
{filteredCombinedUsers.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={usersSelection.has(user.id)}
onCheckedChange={(checked) => {
setUsersSelection((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 || (user.role === "machine" ? "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.role === "machine" ? "Máquina" : "Pessoa"}</td>
<td className="py-3 pr-4 text-neutral-600">
{user.role === "machine" ? (
user.machinePersona ? (
<Badge variant={machinePersonaBadgeVariant(user.machinePersona)} className="rounded-full px-3 py-1 text-xs font-medium">
{formatMachinePersona(user.machinePersona)}
</Badge>
) : (
<span className="text-neutral-500">Sem persona</span>
)
) : (
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>
{user.role === "machine" ? (
<Button variant="ghost" size="sm" asChild>
<Link href={(extractMachineId(user.email) ? `/admin/machines/${extractMachineId(user.email)}` : "/admin/machines")}>Detalhes da máquina</Link>
</Button>
) : null}
<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>
))}
{filteredCombinedUsers.length === 0 ? (
<tr>
<td colSpan={9} className="py-6 text-center text-neutral-500">
{combinedBaseUsers.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="invites" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Gerar convite</CardTitle>
<CardDescription>Envie convites personalizados com validade controlada e acompanhe o status em tempo real.</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleInviteSubmit}
className="grid gap-4 md:grid-cols-2 xl:grid-cols-[minmax(0,2.4fr)_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,1.2fr)_auto]"
>
<div className="grid gap-2 md:col-span-2 xl:col-auto">
<Label htmlFor="invite-email">E-mail corporativo</Label>
<Input
id="invite-email"
type="email"
inputMode="email"
placeholder="nome@suaempresa.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoComplete="off"
/>
</div>
<div className="grid gap-2 md:col-span-2 xl:col-auto">
<Label htmlFor="invite-name">Nome</Label>
<Input
id="invite-name"
placeholder="Nome completo"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="off"
/>
</div>
<div className="grid gap-2 md:col-span-1 xl:col-auto">
<Label>Papel</Label>
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
<SelectTrigger id="invite-role" className="h-9 w-full">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{selectableRoles.map((option) => (
<SelectItem key={option} value={option}>
{formatRole(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-1 xl:col-auto">
<Label>Expira em</Label>
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
<SelectTrigger id="invite-expiration" className="w-full">
<SelectValue placeholder="7 dias" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 dias</SelectItem>
<SelectItem value="14">14 dias</SelectItem>
<SelectItem value="30">30 dias</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end md:col-span-2 xl:col-auto xl:justify-end">
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Gerando..." : "Gerar convite"}
</Button>
</div>
</form>
{lastInviteLink ? (
<div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="font-medium text-neutral-900">Link de convite pronto</p>
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo selecionado.</p>
<code className="mt-2 block rounded bg-white px-3 py-1 text-xs text-neutral-700">{lastInviteLink}</code>
</div>
<Button variant="outline" onClick={() => handleCopy(lastInviteLink)}>Copiar link</Button>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Convites emitidos</CardTitle>
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</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={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">Papel</th>
<th className="py-3 pr-4 font-medium">Espaço</th>
<th className="py-3 pr-4 font-medium">Expira em</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 font-medium">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{invites.map((invite) => (
<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">
<div className="flex flex-col">
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
<span className="text-xs text-neutral-500">{invite.email}</span>
</div>
</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(invite.tenantId, defaultTenantId)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="py-3 pr-4">
<Badge
variant={inviteStatusVariant(invite.status)}
className="rounded-full px-3 py-1 text-xs font-medium"
>
{formatStatus(invite.status)}
</Badge>
</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
Copiar link
</Button>
{invite.status === "pending" && canManageInvite(invite.role) ? (
<Button
variant="ghost"
size="sm"
className="text-red-600 transition-colors hover:bg-red-500/10"
onClick={() => setRevokeDialogInviteId(invite.id)}
disabled={revokingId === invite.id}
>
{revokingId === invite.id ? "Revogando..." : "Revogar"}
</Button>
) : null}
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
<Button
variant="outline"
size="sm"
className="border-amber-400 text-amber-600 hover:bg-amber-50"
onClick={() => handleReactivate(invite)}
disabled={reactivatingId === invite.id}
>
{reactivatingId === invite.id ? "Reativando..." : "Reativar"}
</Button>
) : null}
</div>
</td>
</tr>
))}
{invites.length === 0 ? (
<tr>
<td colSpan={7} className="py-6 text-center text-neutral-500">
Nenhum convite emitido até o momento.
</td>
</tr>
) : null}
</tbody>
</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>
</Card>
</TabsContent>
</Tabs>
<Dialog
open={createDialogOpen}
onOpenChange={(open) => {
if (open) {
setCreateDialogOpen(true)
} else {
handleCloseCreateDialog()
}
}}
>
<DialogContent className="max-w-lg space-y-4">
<DialogHeader>
<DialogTitle>Novo usuário</DialogTitle>
<DialogDescription>Crie uma conta para membros da equipe com acesso imediato ao sistema.</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateUser} className="space-y-5">
<div className="grid gap-2">
<Label htmlFor="create-name">Nome</Label>
<Input
id="create-name"
placeholder="Nome completo"
value={createForm.name}
onChange={(event) => setCreateForm((prev) => ({ ...prev, name: event.target.value }))}
required
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="create-email">E-mail</Label>
<Input
id="create-email"
type="email"
inputMode="email"
placeholder="nome@suaempresa.com"
value={createForm.email}
onChange={(event) => setCreateForm((prev) => ({ ...prev, email: event.target.value }))}
required
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="create-role">Papel</Label>
<Select
value={createForm.role}
onValueChange={(value) => setCreateForm((prev) => ({ ...prev, role: value as RoleOption }))}
>
<SelectTrigger id="create-role" className="h-9">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{selectableRoles.map((option) => (
<SelectItem key={`create-role-${option}`} value={option}>
{formatRole(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="create-tenant">Espaço (tenant)</Label>
<Input
id="create-tenant"
placeholder="tenant-atlas"
value={createForm.tenantId}
onChange={(event) => setCreateForm((prev) => ({ ...prev, tenantId: event.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="create-company">Empresa vinculada</Label>
<Select
value={createForm.companyId}
onValueChange={(value) => setCreateForm((prev) => ({ ...prev, companyId: value }))}
>
<SelectTrigger id="create-company" className="h-9">
<SelectValue placeholder="Sem vínculo" />
</SelectTrigger>
<SelectContent>
{companyOptions.map((company) => (
<SelectItem key={`create-company-${company.id}`} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createPassword ? (
<div className="rounded-lg border border-slate-300 bg-slate-50 p-3">
<p className="text-xs text-neutral-600">Senha temporária gerada:</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
<code className="text-sm font-semibold text-neutral-900">{createPassword}</code>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => navigator.clipboard.writeText(createPassword).then(() => toast.success("Senha copiada"))}
>
Copiar
</Button>
</div>
</div>
) : null}
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={handleCloseCreateDialog} disabled={isCreatingUser}>
Cancelar
</Button>
<Button type="submit" disabled={isCreatingUser}>
{isCreatingUser ? "Criando..." : "Criar usuário"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={bulkDeleteUsersCombinedOpen} onOpenChange={setBulkDeleteUsersCombinedOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover usuários selecionados</DialogTitle>
<DialogDescription>Pessoas perderão o acesso e máquinas serão desconectadas.</DialogDescription>
</DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto">
{Array.from(usersSelection).slice(0, 5).map((id) => {
const u = users.find((x) => x.id === id)
if (!u) return null
return (
<div key={`users-del-${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>
)
})}
{usersSelection.size > 5 ? (
<div className="px-3 text-xs text-neutral-500">+ {usersSelection.size - 5} outros</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteUsersCombinedOpen(false)} disabled={isBulkDeletingUsersCombined}>
Cancelar
</Button>
<Button
variant="destructive"
disabled={isBulkDeletingUsersCombined}
onClick={async () => {
setIsBulkDeletingUsersCombined(true)
try {
await performBulkDeleteUsersCombined(Array.from(usersSelection))
setUsersSelection(new Set())
setBulkDeleteUsersCombinedOpen(false)
toast.success("Remoção concluída")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao excluir selecionados"
toast.error(message)
} finally {
setIsBulkDeletingUsersCombined(false)
}
}}
>
Excluir selecionados
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Sheet open={Boolean(editUser)} onOpenChange={(open) => (!open ? setEditUserId(null) : null)}>
<SheetContent side="right" className="space-y-6 overflow-y-auto px-6 pb-10 sm:max-w-2xl">
<SheetHeader className="px-0 pt-6">
<SheetTitle>Editar usuário</SheetTitle>
<SheetDescription>Atualize os dados cadastrais, papel e vínculo do colaborador.</SheetDescription>
</SheetHeader>
{editUser ? (
<form onSubmit={handleSaveUser} className="space-y-6">
{editingRestricted ? (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
Você pode visualizar este perfil, mas apenas administradores podem alterá-lo.
</div>
) : null}
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Nome</Label>
<Input
value={editForm.name}
onChange={(event) => setEditForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Nome completo"
disabled={isSavingUser || isMachineEditing || editingRestricted}
required
/>
</div>
<div className="grid gap-2">
<Label>E-mail</Label>
<Input
value={editForm.email}
onChange={(event) => setEditForm((prev) => ({ ...prev, email: event.target.value }))}
type="email"
disabled={isSavingUser || isMachineEditing || editingRestricted}
required
/>
</div>
<div className="grid gap-2">
<Label>Papel</Label>
<Select
value={editForm.role}
onValueChange={(value) => setEditForm((prev) => ({ ...prev, role: value as RoleOption }))}
disabled={isSavingUser || isMachineEditing || editingRestricted}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{selectableRoles.map((option) => (
<SelectItem key={option} value={option}>
{formatRole(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Espaço (tenant)</Label>
<Input
value={editForm.tenantId}
onChange={(event) => setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))}
placeholder="tenant-atlas"
disabled={isSavingUser || isMachineEditing || editingRestricted}
/>
</div>
<div className="grid gap-2">
<Label>Empresa vinculada</Label>
<Select
value={editForm.companyId ? editForm.companyId : NO_COMPANY_ID}
onValueChange={(value) =>
setEditForm((prev) => ({ ...prev, companyId: value === NO_COMPANY_ID ? "" : value }))
}
disabled={isSavingUser || editingRestricted}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{companyOptions.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-neutral-500">Essa seleção substitui o vínculo atual no portal do cliente.</p>
</div>
<div className="grid gap-2">
<Label>Máquinas vinculadas</Label>
{linkedMachinesForEditUser.length > 0 ? (
<ul className="divide-y divide-slate-200 rounded-md border border-slate-200 bg-slate-50/60">
{linkedMachinesForEditUser.map((m) => (
<li key={`linked-m-${m.id}`} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="truncate">{m.hostname || m.id}</span>
<Button asChild size="sm" variant="ghost">
<Link href={`/admin/machines/${m.id}`}>Abrir</Link>
</Button>
</li>
))}
</ul>
) : (
<p className="text-xs text-neutral-500">Nenhuma máquina vinculada a este usuário.</p>
)}
</div>
{isMachineEditing ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600">
Os ajustes detalhados de agentes de máquina são feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin Máquinas</Link>.
</div>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-medium text-neutral-900">Gerar nova senha</p>
<p className="text-xs text-neutral-500">Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.</p>
</div>
<Button type="button" variant="outline" onClick={handleResetPassword} disabled={isResettingPassword}>
{isResettingPassword ? "Gerando..." : "Gerar senha"}
</Button>
</div>
{passwordPreview ? (
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-slate-300 bg-white px-3 py-2">
<code className="text-sm font-semibold text-neutral-900">{passwordPreview}</code>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => navigator.clipboard.writeText(passwordPreview).then(() => toast.success("Senha copiada"))}
>
Copiar
</Button>
</div>
) : null}
</div>
)}
</div>
<SheetFooter className="flex flex-col gap-2 sm:flex-row sm:gap-3">
<Button type="button" variant="outline" onClick={() => setEditUserId(null)} disabled={isSavingUser}>
Cancelar
</Button>
<Button type="submit" disabled={isSavingUser || isMachineEditing || editingRestricted} className="sm:ml-auto">
{isSavingUser ? "Salvando..." : "Salvar alterações"}
</Button>
</SheetFooter>
</form>
) : null}
</SheetContent>
</Sheet>
<Dialog
open={deleteUserId !== null}
onOpenChange={(open) => {
if (!open) {
setDeleteUserId(null)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{deleteTarget?.role === "machine" ? "Remover agente de máquina" : "Remover colaborador"}
</DialogTitle>
<DialogDescription>
{deleteTarget?.role === "machine"
? "Revogar o acesso desconecta o aplicativo desktop e exige novo provisionamento."
: "A remoção impede novos acessos, mas não afeta registros históricos de tickets."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm text-neutral-600">
<p>
Confirme a exclusão de <span className="font-medium text-neutral-900">{deleteTarget?.name || deleteTarget?.email}</span>.
</p>
{deleteTarget?.role === "machine" ? (
<p>
A máquina correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.
</p>
) : (
<p>Esse usuário não poderá mais acessar o painel até receber um novo convite.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteUserId(null)} disabled={isDeletingUser}>
Cancelar
</Button>
<Button variant="destructive" onClick={handleDeleteUser} disabled={isDeletingUser}>
{isDeletingUser
? deleteTarget?.role === "machine"
? "Removendo agente..."
: "Removendo..."
: deleteTarget?.role === "machine"
? "Remover agente"
: "Remover"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={revokeDialogInviteId !== null}
onOpenChange={(open) => {
if (!open) {
setRevokeDialogInviteId(null)
setRevokingId(null)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Revogar convite</DialogTitle>
<DialogDescription>
O link atual será invalidado imediatamente. Você pode gerar um novo convite a qualquer momento.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm text-neutral-600">
<p>
Deseja revogar o convite enviado para
{" "}
<span className="font-medium text-neutral-900">{revokeCandidate?.email}</span>?
</p>
<p className="text-xs text-neutral-500">Ação irreversível o convidado verá o link expirado.</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRevokeDialogInviteId(null)} disabled={revokingId !== null}>
Manter convite
</Button>
<Button
variant="destructive"
onClick={handleRevokeConfirmed}
disabled={revokingId === revokeCandidate?.id}
>
{revokingId === revokeCandidate?.id ? "Revogando..." : "Revogar convite"}
</Button>
</DialogFooter>
</DialogContent>
</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>
{/* Dialogs antigos removidos: ações em massa agora são unificadas no diálogo abaixo */}
<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>
</>
)
}