Reorganiza gestão de usuários e remove dados mock
This commit is contained in:
parent
630110bf3a
commit
dded6d1927
20 changed files with 1863 additions and 1368 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
|
||||
import { IconSearch, IconUserPlus } from "@tabler/icons-react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
|
@ -215,6 +216,50 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users])
|
||||
const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
|
||||
|
||||
const defaultCreateRole = (selectableRoles.find((role) => role !== "machine") ?? "agent") as RoleOption
|
||||
const [teamSearch, setTeamSearch] = useState("")
|
||||
const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all")
|
||||
const [machineSearch, setMachineSearch] = useState("")
|
||||
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)
|
||||
|
||||
const filteredTeamUsers = useMemo(() => {
|
||||
const term = teamSearch.trim().toLowerCase()
|
||||
return teamUsers.filter((user) => {
|
||||
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])
|
||||
|
||||
const filteredMachineUsers = useMemo(() => {
|
||||
const term = machineSearch.trim().toLowerCase()
|
||||
if (!term) return machineUsers
|
||||
return machineUsers.filter((user) => {
|
||||
return (
|
||||
(user.name ?? "").toLowerCase().includes(term) ||
|
||||
user.email.toLowerCase().includes(term) ||
|
||||
(user.machinePersona ?? "").toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
}, [machineUsers, machineSearch])
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
|
|
@ -248,6 +293,14 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
})
|
||||
setPasswordPreview(null)
|
||||
}, [editUser, defaultTenantId])
|
||||
useEffect(() => {
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
role: defaultCreateRole,
|
||||
tenantId: defaultTenantId,
|
||||
}))
|
||||
}, [defaultCreateRole, defaultTenantId])
|
||||
|
||||
|
||||
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
|
@ -405,6 +458,115 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveUser(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!editUser) return
|
||||
|
|
@ -549,6 +711,56 @@ async function handleDeleteUser() {
|
|||
</TabsList>
|
||||
|
||||
<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">Equipe cadastrada</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "colaborador" : "colaboradores"}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreateUser} size="sm" className="gap-2 self-start sm:self-auto">
|
||||
<IconUserPlus className="size-4" />
|
||||
Novo usuário
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={teamSearch}
|
||||
onChange={(event) => setTeamSearch(event.target.value)}
|
||||
placeholder="Buscar por nome, e-mail ou empresa..."
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select value={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) => option !== "machine").map((option) => (
|
||||
<SelectItem key={`team-filter-${option}`} value={option}>
|
||||
{formatRole(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTeamSearch("")
|
||||
setTeamRoleFilter("all")
|
||||
}}
|
||||
>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Equipe cadastrada</CardTitle>
|
||||
|
|
@ -568,7 +780,7 @@ async function handleDeleteUser() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{teamUsers.map((user) => (
|
||||
{filteredTeamUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<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>
|
||||
|
|
@ -590,9 +802,9 @@ async function handleDeleteUser() {
|
|||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-500/10"
|
||||
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
|
||||
|
|
@ -605,10 +817,12 @@ async function handleDeleteUser() {
|
|||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{teamUsers.length === 0 ? (
|
||||
{filteredTeamUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-6 text-center text-neutral-500">
|
||||
Nenhum usuário cadastrado até o momento.
|
||||
{teamUsers.length === 0
|
||||
? "Nenhum usuário cadastrado até o momento."
|
||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
|
|
@ -661,6 +875,25 @@ async function handleDeleteUser() {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="machines" className="mt-6 space-y-6">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-neutral-900">Agentes de máquina</p>
|
||||
<p className="text-xs text-neutral-500">{filteredMachineUsers.length} {filteredMachineUsers.length === 1 ? "agente" : "agentes"} vinculados via desktop client.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={machineSearch}
|
||||
onChange={(event) => setMachineSearch(event.target.value)}
|
||||
placeholder="Buscar por hostname ou e-mail técnico"
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agentes de máquina</CardTitle>
|
||||
|
|
@ -678,33 +911,41 @@ async function handleDeleteUser() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{machineUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</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" asChild>
|
||||
<Link href="/admin/machines">Gerenciar</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-500/10"
|
||||
onClick={() => setDeleteUserId(user.id)}
|
||||
>
|
||||
Remover acesso
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{machineUsers.length === 0 ? (
|
||||
{filteredMachineUsers.map((user) => {
|
||||
const machineId = extractMachineId(user.email)
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</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" onClick={() => setEditUserId(user.id)}>
|
||||
Editar
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={machineId ? `/admin/machines/${machineId}` : "/admin/machines"}>Detalhes da máquina</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
onClick={() => setDeleteUserId(user.id)}
|
||||
>
|
||||
Remover acesso
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{filteredMachineUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-neutral-500">
|
||||
Nenhuma máquina provisionada ainda.
|
||||
{machineUsers.length === 0
|
||||
? "Nenhuma máquina provisionada ainda."
|
||||
: "Nenhum agente encontrado para a busca atual."}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
|
|
@ -751,7 +992,7 @@ async function handleDeleteUser() {
|
|||
<div className="grid gap-2">
|
||||
<Label>Papel</Label>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectTrigger id="invite-role" className="h-9">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -888,6 +1129,119 @@ async function handleDeleteUser() {
|
|||
</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.filter((option) => option !== "machine").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>
|
||||
|
||||
|
||||
<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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue