2054 lines
87 KiB
TypeScript
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 & 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>
|
|
</>
|
|
)
|
|
}
|