feat(email): adiciona templates React Email e melhora UI admin
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m33s
Quality Checks / Lint, Test and Build (push) Successful in 3m41s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been cancelled

- Cria 10 novos templates React Email (invite, password-reset, new-login,
  sla-warning, sla-breached, ticket-created, ticket-resolved,
  ticket-assigned, ticket-status, ticket-comment)
- Adiciona envio de email ao criar convite de usuario
- Adiciona security_invite em COLLABORATOR_VISIBLE_TYPES
- Melhora tabela de equipe com badges de papel e colunas fixas
- Atualiza TicketCard com nova interface de props
- Remove botao de limpeza de dados antigos do admin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-17 11:46:02 -03:00
parent 8546a1feb1
commit 498b9789b5
17 changed files with 1422 additions and 190 deletions

View file

@ -2,7 +2,7 @@
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
import { IconSearch, IconUserPlus, IconTrash, IconAlertTriangle, IconPencil } from "@tabler/icons-react"
import { IconSearch, IconUserPlus, IconTrash, IconPencil } from "@tabler/icons-react"
import { toast } from "sonner"
@ -105,11 +105,24 @@ const ROLE_LABELS: Record<string, string> = {
machine: "Agente de dispositivo",
}
const ROLE_BADGE_COLORS: Record<string, string> = {
admin: "bg-neutral-900 text-white border-neutral-900",
manager: "bg-neutral-800 text-white border-neutral-800",
agent: "bg-neutral-700 text-white border-neutral-700",
collaborator: "bg-neutral-600 text-white border-neutral-600",
machine: "bg-neutral-500 text-white border-neutral-500",
}
function formatRole(role: string) {
const key = role?.toLowerCase?.() ?? ""
return ROLE_LABELS[key] ?? role
}
function getRoleBadgeColor(role: string) {
const key = role?.toLowerCase?.() ?? ""
return ROLE_BADGE_COLORS[key] ?? ROLE_BADGE_COLORS.agent
}
function formatMachinePersona(persona: string | null | undefined) {
const normalized = persona?.toLowerCase?.() ?? ""
if (normalized === "manager") return "Gestor"
@ -125,7 +138,6 @@ function machinePersonaBadgeVariant(persona: string | null | undefined) {
}
const ALL_TABS: AdminUsersTab[] = ["team", "users", "invites"]
const DEFAULT_KEEP_EMAILS = ["renan.pac@paulicon.com.br"]
// Tenant removido da UI (sem exibição)
@ -346,25 +358,6 @@ export function AdminUsersManager({
})
const [isCreatingUser, setIsCreatingUser] = useState(false)
const [createPassword, setCreatePassword] = useState<string | null>(null)
const [cleanupDialogOpen, setCleanupDialogOpen] = useState(false)
const [cleanupKeepEmails, setCleanupKeepEmails] = useState(DEFAULT_KEEP_EMAILS.join(", "))
const [cleanupPending, setCleanupPending] = useState(false)
const buildKeepEmailSet = useCallback(() => {
const keep = new Set<string>()
DEFAULT_KEEP_EMAILS.forEach((email) => keep.add(email.toLowerCase()))
if (viewerEmail) {
keep.add(viewerEmail)
}
cleanupKeepEmails
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean)
.forEach((email) => keep.add(email))
return keep
}, [cleanupKeepEmails, viewerEmail])
const cleanupPreview = useMemo(() => Array.from(buildKeepEmailSet()).join(", "), [buildKeepEmailSet])
// Dispositivos (para listar vínculos por usuário)
type MachinesListItem = {
@ -715,46 +708,6 @@ export function AdminUsersManager({
}
}
const handleCleanupConfirm = useCallback(async () => {
if (cleanupPending) return
const keepSet = buildKeepEmailSet()
setCleanupPending(true)
toast.loading("Limpando dados antigos...", { id: "cleanup-users" })
try {
const response = await fetch("/api/admin/users/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keepEmails: Array.from(keepSet) }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível remover os dados antigos.")
}
const summary = (await response.json()) as {
removedPortalUserIds: string[]
removedPortalEmails: string[]
removedConvexUserIds: string[]
removedTicketIds: string[]
keepEmails: string[]
}
setUsers((previous) => previous.filter((user) => !summary.removedPortalUserIds.includes(user.id)))
setUsersSelection((previous) => {
if (previous.size === 0) return previous
const next = new Set(previous)
summary.removedPortalUserIds.forEach((id) => next.delete(id))
return next
})
setCleanupKeepEmails(Array.from(keepSet).join(", "))
toast.success("Dados de teste removidos.", { id: "cleanup-users" })
setCleanupDialogOpen(false)
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível remover os dados antigos."
toast.error(message, { id: "cleanup-users" })
} finally {
setCleanupPending(false)
}
}, [buildKeepEmailSet, cleanupPending, setUsersSelection])
async function handleAssignCompany(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedEmail = linkEmail.trim().toLowerCase()
@ -1164,22 +1117,10 @@ async function handleDeleteUser() {
<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>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button onClick={handleOpenCreateUser} size="sm" className="gap-2 self-start sm:self-auto">
<IconUserPlus className="size-4" />
Novo usuário
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="gap-2 self-start text-amber-600 hover:bg-amber-50 hover:text-amber-700 sm:self-auto"
onClick={() => setCleanupDialogOpen(true)}
>
<IconAlertTriangle className="size-4" />
Limpar dados antigos
</Button>
</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">
@ -1205,6 +1146,7 @@ async function handleDeleteUser() {
placeholder="Todos os papéis"
searchPlaceholder="Buscar papel..."
className="md:w-48"
triggerClassName="h-9 rounded-lg"
/>
<SearchableCombobox
value={teamCompanyFilter}
@ -1213,6 +1155,7 @@ async function handleDeleteUser() {
placeholder="Todas as empresas"
searchPlaceholder="Buscar empresa..."
className="md:w-56"
triggerClassName="h-9 rounded-lg"
/>
{/* Filtro por espaço removido */}
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
@ -1247,51 +1190,53 @@ async function handleDeleteUser() {
</CardHeader>
<CardContent className="space-y-4">
<div className="w-full overflow-x-auto">
<div className="overflow-hidden rounded-lg border">
<Table className="min-w-[900px] table-auto text-sm">
<div className="overflow-hidden rounded-3xl border border-slate-200 shadow-sm">
<Table className="min-w-[1100px] table-fixed text-sm">
<TableHeader className="bg-slate-100/80">
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
<TableHead className="w-16 pl-6 pr-4">
<TableHead className="w-12 pl-4 pr-2">
<Checkbox
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
aria-label="Selecionar todos"
/>
</TableHead>
<TableHead className="px-4">Nome</TableHead>
<TableHead className="px-4 md:w-72">E-mail</TableHead>
<TableHead className="px-4 md:w-44">Papel</TableHead>
<TableHead className="px-4">Empresa</TableHead>
<TableHead className="px-4">Criado em</TableHead>
<TableHead className="px-4 text-right">Ações</TableHead>
<TableHead className="w-[180px] px-3 text-center xl:border-l xl:border-slate-200">Nome</TableHead>
<TableHead className="w-[260px] px-3 text-center xl:border-l xl:border-slate-200">E-mail</TableHead>
<TableHead className="w-[140px] px-3 text-center xl:border-l xl:border-slate-200">Papel</TableHead>
<TableHead className="w-[160px] px-3 text-center xl:border-l xl:border-slate-200">Empresa</TableHead>
<TableHead className="w-[180px] px-3 text-center xl:border-l xl:border-slate-200">Criado em</TableHead>
<TableHead className="w-[100px] px-3 text-center xl:border-l xl:border-slate-200">Acoes</TableHead>
</TableRow>
</TableHeader>
<TableBody className="bg-white">
{teamPaginated.length > 0 ? (
teamPaginated.map((user) => (
<TableRow key={user.id} className="hover:bg-slate-100/70">
<TableCell className="w-16 pl-6 pr-4">
<div className="flex items-center justify-start">
<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>
<TableCell className="w-12 pl-4 pr-2">
<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"
/>
</TableCell>
<TableCell className="px-4 font-medium text-neutral-800">{user.name || "—"}</TableCell>
<TableCell className="px-4 text-neutral-600 break-words">{user.email}</TableCell>
<TableCell className="px-4 text-neutral-600 whitespace-nowrap">{formatRole(user.role)}</TableCell>
<TableCell className="px-4 text-neutral-600">{user.companyName ?? "—"}</TableCell>
<TableCell className="px-4 text-neutral-500">{formatDate(user.createdAt)}</TableCell>
<TableCell className="px-4">
<TableCell className="px-3 font-medium text-neutral-800 truncate">{user.name || "—"}</TableCell>
<TableCell className="px-3 text-neutral-600 truncate">{user.email}</TableCell>
<TableCell className="px-3">
<Badge className={`rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap ${getRoleBadgeColor(user.role)}`}>
{formatRole(user.role)}
</Badge>
</TableCell>
<TableCell className="px-3 text-neutral-600 truncate">{user.companyName ?? "—"}</TableCell>
<TableCell className="px-3 text-neutral-500 whitespace-nowrap">{formatDate(user.createdAt)}</TableCell>
<TableCell className="px-3">
<div className="flex flex-wrap justify-end gap-2">
<Button
size="icon"
@ -1475,6 +1420,7 @@ async function handleDeleteUser() {
placeholder="Todos"
searchPlaceholder="Buscar tipo..."
className="md:w-40"
triggerClassName="h-9 rounded-lg"
/>
<SearchableCombobox
value={usersCompanyFilter}
@ -1483,6 +1429,7 @@ async function handleDeleteUser() {
placeholder="Todas as empresas"
searchPlaceholder="Buscar empresa..."
className="md:w-56"
triggerClassName="h-9 rounded-lg"
/>
{/* Filtro por espaço removido */}
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? (
@ -1517,7 +1464,7 @@ async function handleDeleteUser() {
</CardHeader>
<CardContent className="space-y-4">
<div className="w-full overflow-x-auto">
<div className="overflow-hidden rounded-lg border">
<div className="overflow-hidden rounded-3xl border border-slate-200 shadow-sm">
<Table className="min-w-[960px] table-fixed text-sm">
<TableHeader className="bg-slate-100/80">
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
@ -1534,13 +1481,13 @@ async function handleDeleteUser() {
aria-label="Selecionar todos"
/>
</TableHead>
<TableHead className="px-4">Nome</TableHead>
<TableHead className="px-4">E-mail</TableHead>
<TableHead className="px-4">Tipo</TableHead>
<TableHead className="px-4">Perfil</TableHead>
<TableHead className="px-4">Empresa</TableHead>
<TableHead className="px-4">Criado em</TableHead>
<TableHead className="px-4 text-right">Ações</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Nome</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">E-mail</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Tipo</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Perfil</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Empresa</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Criado em</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody className="bg-white">
@ -1793,7 +1740,7 @@ async function handleDeleteUser() {
</CardHeader>
<CardContent className="space-y-4">
<div className="w-full overflow-x-auto">
<div className="overflow-hidden rounded-lg border">
<div className="overflow-hidden rounded-3xl border border-slate-200 shadow-sm">
<Table className="min-w-[800px] table-fixed text-sm">
<TableHeader className="bg-slate-100/80">
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
@ -1804,11 +1751,11 @@ async function handleDeleteUser() {
aria-label="Selecionar todos"
/>
</TableHead>
<TableHead className="px-4">Colaborador</TableHead>
<TableHead className="px-4">Papel</TableHead>
<TableHead className="px-4">Expira em</TableHead>
<TableHead className="px-4">Status</TableHead>
<TableHead className="px-4 text-right">Ações</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Colaborador</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Papel</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Expira em</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Status</TableHead>
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody className="bg-white">
@ -1961,49 +1908,6 @@ async function handleDeleteUser() {
</TabsContent>
) : null}
</Tabs>
<Dialog
open={cleanupDialogOpen}
onOpenChange={(open) => {
if (!open && cleanupPending) return
setCleanupDialogOpen(open)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover dados de teste</DialogTitle>
<DialogDescription>
Remove usuários, tickets e acessos que não estiverem na lista de e-mails preservada. Esta ação não pode ser desfeita.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="cleanup-keep-emails">E-mails a preservar</Label>
<Input
id="cleanup-keep-emails"
value={cleanupKeepEmails}
onChange={(event) => setCleanupKeepEmails(event.target.value)}
placeholder="email@empresa.com, outro@dominio.com"
/>
<p className="text-xs text-muted-foreground">
Sempre preservamos automaticamente: {viewerEmail ?? "seu e-mail atual"} e{" "}
{DEFAULT_KEEP_EMAILS.join(", ")}.
</p>
</div>
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2">
<p className="text-xs font-semibold text-neutral-700">Lista final preservada</p>
<p className="text-xs text-neutral-500 break-all">{cleanupPreview}</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCleanupDialogOpen(false)} disabled={cleanupPending}>
Cancelar
</Button>
<Button variant="destructive" onClick={handleCleanupConfirm} disabled={cleanupPending}>
{cleanupPending ? "Removendo..." : "Remover dados"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={createDialogOpen}
onOpenChange={(open) => {

View file

@ -788,11 +788,11 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
<Table className="w-full table-auto">
<TableHeader className="bg-slate-100/80 border-b border-slate-200">
<TableRow>
<TableHead>Empresa</TableHead>
<TableHead>Contratos ativos</TableHead>
<TableHead>Contatos</TableHead>
<TableHead>Dispositivos</TableHead>
<TableHead className="text-right">Ações</TableHead>
<TableHead className="text-center">Empresa</TableHead>
<TableHead className="text-center">Contratos ativos</TableHead>
<TableHead className="text-center">Contatos</TableHead>
<TableHead className="text-center">Dispositivos</TableHead>
<TableHead className="text-center">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View file

@ -227,10 +227,10 @@ function AccountsTable({
const effectiveTenantId = tenantId || DEFAULT_TENANT_ID
const headerCellClass =
"px-3 py-3 text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-4 last:pr-4"
"px-3 py-3 text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-4 last:pr-4 xl:border-l xl:border-slate-200 first:xl:border-l-0"
const cellClass =
"px-3 py-4 text-sm text-neutral-700 first:pl-4 last:pr-4 whitespace-pre-wrap leading-snug"
const rowClass = "border-b border-border/60 text-sm transition-colors hover:bg-muted/40 last:border-b-0"
const rowClass = "border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-b-0"
const metaLabelClass =
"text-[11px] font-semibold uppercase tracking-wide text-neutral-500"
const metaValueClass = "text-sm text-neutral-600 leading-tight break-words"
@ -689,10 +689,10 @@ function AccountsTable({
<>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Usuários do cliente</CardTitle>
<CardDescription>Gestores e colaboradores com acesso ao portal de chamados.</CardDescription>
<CardTitle className="text-base font-semibold">Usuários</CardTitle>
<CardDescription>Gestores e colaboradores com acesso ao portal.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-1 flex-wrap gap-3">
<div className="relative flex-1 min-w-[16rem]">
@ -718,6 +718,7 @@ function AccountsTable({
placeholder="Todos os papéis"
searchPlaceholder="Buscar papel..."
className="md:w-[12rem]"
triggerClassName="h-9 rounded-lg"
/>
</div>
<SearchableCombobox
@ -727,16 +728,18 @@ function AccountsTable({
placeholder="Todas as empresas"
searchPlaceholder="Buscar empresa..."
className="md:w-[16rem]"
triggerClassName="h-9 rounded-lg"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button type="button" className="gap-2" onClick={handleOpenCreateDialog}>
<Button type="button" size="sm" className="gap-2" onClick={handleOpenCreateDialog}>
<IconUserPlus className="size-4" />
Novo usuário
</Button>
<Button
variant="destructive"
size="sm"
disabled={selectedIds.length === 0 || isPending}
onClick={() => openDeleteDialog(selectedIds)}
>
@ -746,9 +749,9 @@ function AccountsTable({
</div>
</div>
<div className="w-full overflow-hidden rounded-2xl border border-border/60 bg-background">
<div className="w-full overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm">
<Table className="w-full text-sm">
<TableHeader className="bg-muted/60">
<TableHeader className="bg-slate-100/80 border-b border-slate-200">
<TableRow className="bg-transparent">
<TableHead className="w-12 px-3">
<Checkbox
@ -891,7 +894,7 @@ function AccountsTable({
)}
</TableCell>
<TableCell className={cn(cellClass, "align-middle text-center text-neutral-700")}>
<Badge variant="secondary" className="mx-auto bg-neutral-900 text-white hover:bg-neutral-900">
<Badge className="mx-auto rounded-full border border-slate-200 bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-700">
{ROLE_LABEL[account.role]}
</Badge>
</TableCell>
@ -910,17 +913,17 @@ function AccountsTable({
size="icon"
disabled={!account.authUserId || isPending}
onClick={() => handleOpenEditor(account)}
title="Editar usuário"
aria-label="Editar usuário"
>
<IconPencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-rose-600 hover:bg-rose-50 hover:text-rose-700"
className="border border-transparent text-rose-600 hover:border-rose-300 hover:bg-rose-50 hover:text-rose-700"
disabled={isPending}
onClick={() => openDeleteDialog([account.id])}
title="Remover usuário"
aria-label="Remover usuário"
>
<IconTrash className="size-4" />
</Button>
@ -1050,10 +1053,8 @@ function AccountsTable({
}))
}
options={editManagerOptions}
placeholder="Sem gestor definido"
placeholder="Buscar gestor..."
searchPlaceholder="Buscar gestor..."
allowClear
clearLabel="Remover gestor"
disabled={isSavingAccount}
/>
</div>
@ -1197,10 +1198,8 @@ function AccountsTable({
}))
}
options={managerOptions}
placeholder="Sem gestor definido"
placeholder="Buscar gestor..."
searchPlaceholder="Buscar gestor..."
allowClear
clearLabel="Remover gestor"
disabled={isCreatingAccount}
/>
</div>