From 498b9789b57b7ee9dafd542829d86e9cac56da52 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Wed, 17 Dec 2025 11:46:02 -0300 Subject: [PATCH] feat(email): adiciona templates React Email e melhora UI admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- convex/reactEmail.tsx | 65 ++++- emails/_components/ticket-card.tsx | 102 +++++++- emails/invite-email.tsx | 132 ++++++++++ emails/new-login-email.tsx | 150 +++++++++++ emails/password-reset-email.tsx | 81 ++++++ emails/sla-breached-email.tsx | 151 ++++++++++++ emails/sla-warning-email.tsx | 139 +++++++++++ emails/ticket-assigned-email.tsx | 82 +++++++ emails/ticket-comment-email.tsx | 113 +++++++++ emails/ticket-created-email.tsx | 80 ++++++ emails/ticket-resolved-email.tsx | 121 +++++++++ emails/ticket-status-email.tsx | 85 +++++++ src/app/api/admin/invites/route.ts | 33 ++- .../api/notifications/preferences/route.ts | 1 + src/components/admin/admin-users-manager.tsx | 232 +++++------------- .../companies/admin-companies-manager.tsx | 10 +- .../admin/users/admin-users-workspace.tsx | 35 ++- 17 files changed, 1422 insertions(+), 190 deletions(-) create mode 100644 emails/invite-email.tsx create mode 100644 emails/new-login-email.tsx create mode 100644 emails/password-reset-email.tsx create mode 100644 emails/sla-breached-email.tsx create mode 100644 emails/sla-warning-email.tsx create mode 100644 emails/ticket-assigned-email.tsx create mode 100644 emails/ticket-comment-email.tsx create mode 100644 emails/ticket-created-email.tsx create mode 100644 emails/ticket-resolved-email.tsx create mode 100644 emails/ticket-status-email.tsx diff --git a/convex/reactEmail.tsx b/convex/reactEmail.tsx index b81fade..7ec9382 100644 --- a/convex/reactEmail.tsx +++ b/convex/reactEmail.tsx @@ -3,8 +3,31 @@ import { render } from "@react-email/render" import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email" import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-email" +import InviteEmail, { type InviteEmailProps } from "../emails/invite-email" +import PasswordResetEmail, { type PasswordResetEmailProps } from "../emails/password-reset-email" +import NewLoginEmail, { type NewLoginEmailProps } from "../emails/new-login-email" +import SlaWarningEmail, { type SlaWarningEmailProps } from "../emails/sla-warning-email" +import SlaBreachedEmail, { type SlaBreachedEmailProps } from "../emails/sla-breached-email" +import TicketCreatedEmail, { type TicketCreatedEmailProps } from "../emails/ticket-created-email" +import TicketResolvedEmail, { type TicketResolvedEmailProps } from "../emails/ticket-resolved-email" +import TicketAssignedEmail, { type TicketAssignedEmailProps } from "../emails/ticket-assigned-email" +import TicketStatusEmail, { type TicketStatusEmailProps } from "../emails/ticket-status-email" +import TicketCommentEmail, { type TicketCommentEmailProps } from "../emails/ticket-comment-email" -export type { AutomationEmailProps, SimpleNotificationEmailProps } +export type { + AutomationEmailProps, + SimpleNotificationEmailProps, + InviteEmailProps, + PasswordResetEmailProps, + NewLoginEmailProps, + SlaWarningEmailProps, + SlaBreachedEmailProps, + TicketCreatedEmailProps, + TicketResolvedEmailProps, + TicketAssignedEmailProps, + TicketStatusEmailProps, + TicketCommentEmailProps, +} export async function renderAutomationEmailHtml(props: AutomationEmailProps) { return render(, { pretty: false }) @@ -13,3 +36,43 @@ export async function renderAutomationEmailHtml(props: AutomationEmailProps) { export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) { return render(, { pretty: false }) } + +export async function renderInviteEmailHtml(props: InviteEmailProps) { + return render(, { pretty: false }) +} + +export async function renderPasswordResetEmailHtml(props: PasswordResetEmailProps) { + return render(, { pretty: false }) +} + +export async function renderNewLoginEmailHtml(props: NewLoginEmailProps) { + return render(, { pretty: false }) +} + +export async function renderSlaWarningEmailHtml(props: SlaWarningEmailProps) { + return render(, { pretty: false }) +} + +export async function renderSlaBreachedEmailHtml(props: SlaBreachedEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketCreatedEmailHtml(props: TicketCreatedEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketResolvedEmailHtml(props: TicketResolvedEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketAssignedEmailHtml(props: TicketAssignedEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketStatusEmailHtml(props: TicketStatusEmailProps) { + return render(, { pretty: false }) +} + +export async function renderTicketCommentEmailHtml(props: TicketCommentEmailProps) { + return render(, { pretty: false }) +} diff --git a/emails/_components/ticket-card.tsx b/emails/_components/ticket-card.tsx index 31ac291..d623acc 100644 --- a/emails/_components/ticket-card.tsx +++ b/emails/_components/ticket-card.tsx @@ -14,6 +14,18 @@ export type TicketCardData = { assigneeName?: string | null } +export type TicketCardProps = { + ticketNumber: string + ticketTitle: string + status?: string | null + priority?: string | null + category?: string | null + subcategory?: string | null + companyName?: string | null + requesterName?: string | null + assigneeName?: string | null +} + function badge(label: string, bg: string, color: string) { return ( ) } + +export function TicketCard(props: TicketCardProps) { + const { ticketNumber, ticketTitle, status, priority, category, subcategory, companyName, requesterName, assigneeName } = props + const categoryLabel = category && subcategory ? `${category} / ${subcategory}` : category ?? subcategory ?? null + + return ( +
+ + + + + + + + + +
+ + Chamado #{ticketNumber} + + + {ticketTitle} + +
+ + + {status ? ( + + + + + ) : null} + {priority ? ( + + + + + ) : null} + {categoryLabel ? ( + + + + + ) : null} + {companyName ? ( + + + + + ) : null} + {requesterName ? ( + + + + + ) : null} + {assigneeName ? ( + + + + + ) : null} + +
+ Status + {statusBadge(status)}
+ Prioridade + {priorityBadge(priority)}
+ Categoria + {categoryLabel}
+ Empresa + {companyName}
+ Solicitante + {requesterName}
+ Responsavel + {assigneeName}
+
+
+ ) +} diff --git a/emails/invite-email.tsx b/emails/invite-email.tsx new file mode 100644 index 0000000..6e7b794 --- /dev/null +++ b/emails/invite-email.tsx @@ -0,0 +1,132 @@ +import * as React from "react" +import { Button, Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { EMAIL_COLORS } from "./_components/tokens" + +export type InviteEmailProps = { + inviterName: string + roleName: string + companyName?: string | null + inviteUrl: string +} + +export default function InviteEmail(props: InviteEmailProps) { + const { inviterName, roleName, companyName, inviteUrl } = props + + return ( + +
+
+ 🎉 +
+
+ + + Voce foi convidado! + + + + {inviterName} convidou voce para acessar o Sistema de Chamados Raven. + + +
+ + + + + + {companyName ? ( + + + + ) : null} + +
+ + + + + + + +
+ Funcao + + {roleName} +
+
+ + + + + + + +
+ Empresa + + {companyName} +
+
+
+ +
+ +
+ +
+ + + Se o botao nao funcionar, copie e cole esta URL no navegador: +
+ + {inviteUrl} + +
+ + + Este convite expira em 7 dias. Se voce nao esperava este convite, pode ignora-lo com seguranca. + +
+ ) +} + +InviteEmail.PreviewProps = { + inviterName: "Renan Oliveira", + roleName: "Agente", + companyName: "Paulicon", + inviteUrl: "https://raven.rever.com.br/invite/abc123def456", +} satisfies InviteEmailProps diff --git a/emails/new-login-email.tsx b/emails/new-login-email.tsx new file mode 100644 index 0000000..6e2ef20 --- /dev/null +++ b/emails/new-login-email.tsx @@ -0,0 +1,150 @@ +import * as React from "react" +import { Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { EMAIL_COLORS } from "./_components/tokens" + +export type NewLoginEmailProps = { + loginAt: string + ipAddress: string + userAgent: string + location?: string | null +} + +function formatDate(dateStr: string): string { + try { + const date = new Date(dateStr) + return new Intl.DateTimeFormat("pt-BR", { + dateStyle: "long", + timeStyle: "short", + }).format(date) + } catch { + return dateStr + } +} + +export default function NewLoginEmail(props: NewLoginEmailProps) { + const { loginAt, ipAddress, userAgent, location } = props + + return ( + +
+
+ 🔒 +
+
+ + + Novo acesso detectado + + + + Detectamos um novo acesso a sua conta. Se foi voce, pode ignorar este e-mail. + + +
+ + + + + + + + + {location ? ( + + + + ) : null} + + + + +
+ + + + + + + +
+ Data/Hora + + {formatDate(loginAt)} +
+
+ + + + + + + +
+ Endereco IP + + {ipAddress} +
+
+ + + + + + + +
+ Localizacao + + {location} +
+
+ + + + + + + +
+ Dispositivo + + {userAgent} +
+
+
+ +
+ + + Se voce nao reconhece este acesso, recomendamos que altere sua senha imediatamente. + +
+ ) +} + +NewLoginEmail.PreviewProps = { + loginAt: new Date().toISOString(), + ipAddress: "192.168.1.100", + userAgent: "Chrome 120.0 / Windows 11", + location: "Sao Paulo, SP, Brasil", +} satisfies NewLoginEmailProps diff --git a/emails/password-reset-email.tsx b/emails/password-reset-email.tsx new file mode 100644 index 0000000..b0db729 --- /dev/null +++ b/emails/password-reset-email.tsx @@ -0,0 +1,81 @@ +import * as React from "react" +import { Button, Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { EMAIL_COLORS } from "./_components/tokens" + +export type PasswordResetEmailProps = { + resetUrl: string + expiresIn?: string +} + +export default function PasswordResetEmail(props: PasswordResetEmailProps) { + const { resetUrl, expiresIn = "1 hora" } = props + + return ( + +
+
+ 🔒 +
+
+ + + Redefinir senha + + + + Recebemos uma solicitacao para redefinir a senha da sua conta. Clique no botao abaixo para criar uma nova senha. + + +
+ +
+ +
+ + + Se o botao nao funcionar, copie e cole esta URL no navegador: +
+ + {resetUrl} + +
+ + + Este link expira em {expiresIn}. Se voce nao solicitou esta redefinicao, pode ignorar este e-mail com seguranca. + +
+ ) +} + +PasswordResetEmail.PreviewProps = { + resetUrl: "https://raven.rever.com.br/redefinir-senha?token=abc123def456", + expiresIn: "1 hora", +} satisfies PasswordResetEmailProps diff --git a/emails/sla-breached-email.tsx b/emails/sla-breached-email.tsx new file mode 100644 index 0000000..a30f916 --- /dev/null +++ b/emails/sla-breached-email.tsx @@ -0,0 +1,151 @@ +import * as React from "react" +import { Button, Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { EMAIL_COLORS } from "./_components/tokens" + +export type SlaBreachedEmailProps = { + ticketNumber: string + ticketTitle: string + breachedAt: string + ticketUrl: string +} + +function formatDate(dateStr: string): string { + try { + const date = new Date(dateStr) + return new Intl.DateTimeFormat("pt-BR", { + dateStyle: "long", + timeStyle: "short", + }).format(date) + } catch { + return dateStr + } +} + +export default function SlaBreachedEmail(props: SlaBreachedEmailProps) { + const { ticketNumber, ticketTitle, breachedAt, ticketUrl } = props + + return ( + +
+
+ 🚨 +
+
+ + + SLA estourado + + + + O chamado abaixo excedeu o tempo de atendimento acordado e requer atencao imediata. + + +
+ + + + + + + + + + + + +
+ + + + + + + +
+ Chamado + + #{ticketNumber} +
+
+ + + + + + + +
+ Titulo + + {ticketTitle} +
+
+ + + + + + + +
+ Estourado em + + {formatDate(breachedAt)} +
+
+
+ +
+ +
+ +
+ + + Este chamado deve ser tratado com prioridade maxima. + +
+ ) +} + +SlaBreachedEmail.PreviewProps = { + ticketNumber: "41025", + ticketTitle: "Computador nao liga apos atualizacao", + breachedAt: new Date().toISOString(), + ticketUrl: "https://raven.rever.com.br/tickets/abc123", +} satisfies SlaBreachedEmailProps diff --git a/emails/sla-warning-email.tsx b/emails/sla-warning-email.tsx new file mode 100644 index 0000000..c9d66ee --- /dev/null +++ b/emails/sla-warning-email.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import { Button, Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { EMAIL_COLORS } from "./_components/tokens" + +export type SlaWarningEmailProps = { + ticketNumber: string + ticketTitle: string + timeRemaining: string + ticketUrl: string +} + +export default function SlaWarningEmail(props: SlaWarningEmailProps) { + const { ticketNumber, ticketTitle, timeRemaining, ticketUrl } = props + + return ( + +
+
+ ⚠ +
+
+ + + Alerta de SLA + + + + O chamado abaixo esta proximo de estourar o tempo de atendimento acordado. + + +
+ + + + + + + + + + + + +
+ + + + + + + +
+ Chamado + + #{ticketNumber} +
+
+ + + + + + + +
+ Titulo + + {ticketTitle} +
+
+ + + + + + + +
+ Tempo restante + + {timeRemaining} +
+
+
+ +
+ +
+ +
+ + + Acesse o sistema para mais detalhes e acompanhe o status do chamado. + +
+ ) +} + +SlaWarningEmail.PreviewProps = { + ticketNumber: "41025", + ticketTitle: "Computador nao liga apos atualizacao", + timeRemaining: "45 minutos", + ticketUrl: "https://raven.rever.com.br/tickets/abc123", +} satisfies SlaWarningEmailProps diff --git a/emails/ticket-assigned-email.tsx b/emails/ticket-assigned-email.tsx new file mode 100644 index 0000000..a97ac23 --- /dev/null +++ b/emails/ticket-assigned-email.tsx @@ -0,0 +1,82 @@ +import * as React from "react" +import { Button, Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { TicketCard, type TicketCardProps } from "./_components/ticket-card" +import { EMAIL_COLORS } from "./_components/tokens" + +export type TicketAssignedEmailProps = TicketCardProps & { + ticketUrl: string + assigneeName: string +} + +export default function TicketAssignedEmail(props: TicketAssignedEmailProps) { + const { ticketUrl, assigneeName, ...ticketProps } = props + + return ( + +
+
+ 👤 +
+
+ + + Chamado atribuido + + + + O chamado foi atribuido a {assigneeName}. + + + + +
+ +
+ +
+ + + Voce recebera atualizacoes por e-mail quando houver novidades. + +
+ ) +} + +TicketAssignedEmail.PreviewProps = { + ticketNumber: "41025", + ticketTitle: "Computador nao liga apos atualizacao", + status: "AWAITING_ATTENDANCE", + priority: "HIGH", + category: "Hardware", + subcategory: "Desktop", + ticketUrl: "https://raven.rever.com.br/tickets/abc123", + assigneeName: "Weslei Magalhaes", +} satisfies TicketAssignedEmailProps diff --git a/emails/ticket-comment-email.tsx b/emails/ticket-comment-email.tsx new file mode 100644 index 0000000..c3cebc2 --- /dev/null +++ b/emails/ticket-comment-email.tsx @@ -0,0 +1,113 @@ +import * as React from "react" +import { Button, Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { EMAIL_COLORS } from "./_components/tokens" + +export type TicketCommentEmailProps = { + ticketNumber: string + ticketTitle: string + commenterName: string + commentPreview: string + ticketUrl: string +} + +export default function TicketCommentEmail(props: TicketCommentEmailProps) { + const { ticketNumber, ticketTitle, commenterName, commentPreview, ticketUrl } = props + + return ( + +
+
+ 💬 +
+
+ + + Novo comentario + + + + {commenterName} comentou no chamado #{ticketNumber}. + + +
+ + + + + + + + + +
+ + Chamado #{ticketNumber} + + + {ticketTitle} + +
+ + Comentario + + + {commentPreview} + +
+
+ +
+ +
+ +
+ + + Clique no botao acima para ver o comentario completo e responder. + +
+ ) +} + +TicketCommentEmail.PreviewProps = { + ticketNumber: "41025", + ticketTitle: "Computador nao liga apos atualizacao", + commenterName: "Weslei Magalhaes", + commentPreview: "Ola! Ja verificamos o problema e parece ser relacionado ao driver da placa de video. Vou precisar de acesso remoto para fazer a correcao...", + ticketUrl: "https://raven.rever.com.br/tickets/abc123", +} satisfies TicketCommentEmailProps diff --git a/emails/ticket-created-email.tsx b/emails/ticket-created-email.tsx new file mode 100644 index 0000000..ff187c8 --- /dev/null +++ b/emails/ticket-created-email.tsx @@ -0,0 +1,80 @@ +import * as React from "react" +import { Button, Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { TicketCard, type TicketCardProps } from "./_components/ticket-card" +import { EMAIL_COLORS } from "./_components/tokens" + +export type TicketCreatedEmailProps = TicketCardProps & { + ticketUrl: string +} + +export default function TicketCreatedEmail(props: TicketCreatedEmailProps) { + const { ticketUrl, ...ticketProps } = props + + return ( + +
+
+ ✅ +
+
+ + + Chamado criado + + + + Seu chamado foi registrado com sucesso e ja esta sendo processado pela nossa equipe. + + + + +
+ +
+ +
+ + + Voce recebera atualizacoes por e-mail quando houver novidades. + +
+ ) +} + +TicketCreatedEmail.PreviewProps = { + ticketNumber: "41025", + ticketTitle: "Computador nao liga apos atualizacao", + status: "PENDING", + priority: "HIGH", + category: "Hardware", + subcategory: "Desktop", + ticketUrl: "https://raven.rever.com.br/tickets/abc123", +} satisfies TicketCreatedEmailProps diff --git a/emails/ticket-resolved-email.tsx b/emails/ticket-resolved-email.tsx new file mode 100644 index 0000000..a1e84e3 --- /dev/null +++ b/emails/ticket-resolved-email.tsx @@ -0,0 +1,121 @@ +import * as React from "react" +import { Button, Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { TicketCard, type TicketCardProps } from "./_components/ticket-card" +import { EMAIL_COLORS } from "./_components/tokens" + +export type TicketResolvedEmailProps = TicketCardProps & { + ticketUrl: string + ratingUrl?: string | null + resolution?: string | null +} + +export default function TicketResolvedEmail(props: TicketResolvedEmailProps) { + const { ticketUrl, ratingUrl, resolution, ...ticketProps } = props + + return ( + +
+
+ 🎉 +
+
+ + + Chamado resolvido + + + + Seu chamado foi marcado como resolvido. Confira os detalhes abaixo. + + + + + {resolution ? ( +
+ + Resolucao + + + {resolution} + +
+ ) : null} + +
+ + {ratingUrl ? ( + + ) : null} +
+ +
+ + + Sua opiniao e importante! Avalie o atendimento para nos ajudar a melhorar. + +
+ ) +} + +TicketResolvedEmail.PreviewProps = { + ticketNumber: "41025", + ticketTitle: "Computador nao liga apos atualizacao", + status: "RESOLVED", + priority: "HIGH", + category: "Hardware", + subcategory: "Desktop", + ticketUrl: "https://raven.rever.com.br/tickets/abc123", + ratingUrl: "https://raven.rever.com.br/rate/abc123", + resolution: "Problema resolvido apos atualizacao do driver da placa de video e reinicializacao do sistema.", +} satisfies TicketResolvedEmailProps diff --git a/emails/ticket-status-email.tsx b/emails/ticket-status-email.tsx new file mode 100644 index 0000000..2d50d45 --- /dev/null +++ b/emails/ticket-status-email.tsx @@ -0,0 +1,85 @@ +import * as React from "react" +import { Button, Heading, Hr, Section, Text } from "@react-email/components" + +import { RavenEmailLayout } from "./_components/layout" +import { TicketCard, type TicketCardProps } from "./_components/ticket-card" +import { EMAIL_COLORS } from "./_components/tokens" +import { formatStatus } from "./_components/utils" + +export type TicketStatusEmailProps = TicketCardProps & { + ticketUrl: string + previousStatus: string + newStatus: string +} + +export default function TicketStatusEmail(props: TicketStatusEmailProps) { + const { ticketUrl, previousStatus, newStatus, ...ticketProps } = props + + return ( + +
+
+ 🔄 +
+
+ + + Status atualizado + + + + O status do seu chamado foi alterado de {formatStatus(previousStatus)} para {formatStatus(newStatus)}. + + + + +
+ +
+ +
+ + + Voce recebera atualizacoes por e-mail quando houver novidades. + +
+ ) +} + +TicketStatusEmail.PreviewProps = { + ticketNumber: "41025", + ticketTitle: "Computador nao liga apos atualizacao", + status: "AWAITING_ATTENDANCE", + priority: "HIGH", + category: "Hardware", + subcategory: "Desktop", + ticketUrl: "https://raven.rever.com.br/tickets/abc123", + previousStatus: "PENDING", + newStatus: "AWAITING_ATTENDANCE", +} satisfies TicketStatusEmailProps diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts index 958513e..6093176 100644 --- a/src/app/api/admin/invites/route.ts +++ b/src/app/api/admin/invites/route.ts @@ -10,7 +10,8 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants" import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz" import { env } from "@/lib/env" import { prisma } from "@/lib/prisma" -import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils" +import { buildInviteUrl, computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils" +import { notifyUserInvite } from "@/server/notification/notification-service" const DEFAULT_EXPIRATION_DAYS = 7 const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput @@ -27,6 +28,17 @@ function normalizeRole(input: string | null | undefined): RoleOption { return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent" } +const ROLE_LABELS: Record = { + admin: "Administrador", + manager: "Gestor", + agent: "Agente", + collaborator: "Colaborador", +} + +function formatRoleName(role: string): string { + return ROLE_LABELS[role.toLowerCase()] ?? role +} + function generateToken() { return randomBytes(32).toString("hex") } @@ -213,5 +225,24 @@ export async function POST(request: Request) { const normalized = buildInvitePayload(inviteWithEvents, now) await syncInviteWithConvex(normalized) + // Envia email de convite + const inviteUrl = buildInviteUrl(token) + const inviterName = session.user.name ?? session.user.email + const roleName = formatRoleName(role) + + try { + await notifyUserInvite( + email, + name ?? null, + inviterName, + roleName, + null, // companyName - não temos essa informação no convite + inviteUrl + ) + } catch (error) { + // Log do erro mas não falha a criação do convite + console.error("[invites] Falha ao enviar email de convite:", error) + } + return NextResponse.json({ invite: normalized }) } diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 8255123..2556a18 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -27,6 +27,7 @@ const COLLABORATOR_VISIBLE_TYPES: NotificationType[] = [ "security_email_verify", "security_email_change", "security_new_login", + "security_invite", ] export async function GET(_request: NextRequest) { diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index 9e172f8..756199d 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -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 = { machine: "Agente de dispositivo", } +const ROLE_BADGE_COLORS: Record = { + 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(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() - 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) { event.preventDefault() const normalizedEmail = linkEmail.trim().toLowerCase() @@ -1164,22 +1117,10 @@ async function handleDeleteUser() {

Equipe cadastrada

{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "membro" : "membros"}

-
- - -
+
@@ -1205,6 +1146,7 @@ async function handleDeleteUser() { placeholder="Todos os papéis" searchPlaceholder="Buscar papel..." className="md:w-48" + triggerClassName="h-9 rounded-lg" /> {/* Filtro por espaço removido */} {(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? ( @@ -1247,51 +1190,53 @@ async function handleDeleteUser() {
-
- +
+
- + toggleTeamSelectAll(!!value)} aria-label="Selecionar todos" /> - Nome - E-mail - Papel - Empresa - Criado em - Ações + Nome + E-mail + Papel + Empresa + Criado em + Acoes {teamPaginated.length > 0 ? ( teamPaginated.map((user) => ( - -
- { - setTeamSelection((prev) => { - const next = new Set(prev) - if (checked) next.add(user.id) - else next.delete(user.id) - return next - }) - }} - aria-label="Selecionar linha" - /> -
+ + { + setTeamSelection((prev) => { + const next = new Set(prev) + if (checked) next.add(user.id) + else next.delete(user.id) + return next + }) + }} + aria-label="Selecionar linha" + /> - {user.name || "—"} - {user.email} - {formatRole(user.role)} - {user.companyName ?? "—"} - {formatDate(user.createdAt)} - + {user.name || "—"} + {user.email} + + + {formatRole(user.role)} + + + {user.companyName ?? "—"} + {formatDate(user.createdAt)} +
@@ -1534,13 +1481,13 @@ async function handleDeleteUser() { aria-label="Selecionar todos" /> - Nome - E-mail - Tipo - Perfil - Empresa - Criado em - Ações + Nome + E-mail + Tipo + Perfil + Empresa + Criado em + Ações @@ -1793,7 +1740,7 @@ async function handleDeleteUser() {
-
+
@@ -1804,11 +1751,11 @@ async function handleDeleteUser() { aria-label="Selecionar todos" /> - Colaborador - Papel - Expira em - Status - Ações + Colaborador + Papel + Expira em + Status + Ações @@ -1961,49 +1908,6 @@ async function handleDeleteUser() { ) : null} - { - if (!open && cleanupPending) return - setCleanupDialogOpen(open) - }} - > - - - Remover dados de teste - - Remove usuários, tickets e acessos que não estiverem na lista de e-mails preservada. Esta ação não pode ser desfeita. - - -
-
- - setCleanupKeepEmails(event.target.value)} - placeholder="email@empresa.com, outro@dominio.com" - /> -

- Sempre preservamos automaticamente: {viewerEmail ?? "seu e-mail atual"} e{" "} - {DEFAULT_KEEP_EMAILS.join(", ")}. -

-
-
-

Lista final preservada

-

{cleanupPreview}

-
-
- - - - -
-
{ diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index d0f02d6..53de367 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -788,11 +788,11 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
- Empresa - Contratos ativos - Contatos - Dispositivos - Ações + Empresa + Contratos ativos + Contatos + Dispositivos + Ações diff --git a/src/components/admin/users/admin-users-workspace.tsx b/src/components/admin/users/admin-users-workspace.tsx index 9699949..e607d13 100644 --- a/src/components/admin/users/admin-users-workspace.tsx +++ b/src/components/admin/users/admin-users-workspace.tsx @@ -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({ <> - Usuários do cliente - Gestores e colaboradores com acesso ao portal de chamados. + Usuários + Gestores e colaboradores com acesso ao portal. - +
@@ -718,6 +718,7 @@ function AccountsTable({ placeholder="Todos os papéis" searchPlaceholder="Buscar papel..." className="md:w-[12rem]" + triggerClassName="h-9 rounded-lg" />
-
-
+
- + - + {ROLE_LABEL[account.role]} @@ -910,17 +913,17 @@ function AccountsTable({ size="icon" disabled={!account.authUserId || isPending} onClick={() => handleOpenEditor(account)} - title="Editar usuário" + aria-label="Editar usuário" > @@ -1050,10 +1053,8 @@ function AccountsTable({ })) } options={editManagerOptions} - placeholder="Sem gestor definido" + placeholder="Buscar gestor..." searchPlaceholder="Buscar gestor..." - allowClear - clearLabel="Remover gestor" disabled={isSavingAccount} /> @@ -1197,10 +1198,8 @@ function AccountsTable({ })) } options={managerOptions} - placeholder="Sem gestor definido" + placeholder="Buscar gestor..." searchPlaceholder="Buscar gestor..." - allowClear - clearLabel="Remover gestor" disabled={isCreatingAccount} />