feat(email): adiciona templates React Email e melhora UI admin
Some checks failed
Some checks failed
- 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:
parent
8546a1feb1
commit
498b9789b5
17 changed files with 1422 additions and 190 deletions
|
|
@ -3,8 +3,31 @@ import { render } from "@react-email/render"
|
||||||
|
|
||||||
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
|
import AutomationEmail, { type AutomationEmailProps } from "../emails/automation-email"
|
||||||
import SimpleNotificationEmail, { type SimpleNotificationEmailProps } from "../emails/simple-notification-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) {
|
export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
|
||||||
return render(<AutomationEmail {...props} />, { pretty: false })
|
return render(<AutomationEmail {...props} />, { pretty: false })
|
||||||
|
|
@ -13,3 +36,43 @@ export async function renderAutomationEmailHtml(props: AutomationEmailProps) {
|
||||||
export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) {
|
export async function renderSimpleNotificationEmailHtml(props: SimpleNotificationEmailProps) {
|
||||||
return render(<SimpleNotificationEmail {...props} />, { pretty: false })
|
return render(<SimpleNotificationEmail {...props} />, { pretty: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renderInviteEmailHtml(props: InviteEmailProps) {
|
||||||
|
return render(<InviteEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPasswordResetEmailHtml(props: PasswordResetEmailProps) {
|
||||||
|
return render(<PasswordResetEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderNewLoginEmailHtml(props: NewLoginEmailProps) {
|
||||||
|
return render(<NewLoginEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderSlaWarningEmailHtml(props: SlaWarningEmailProps) {
|
||||||
|
return render(<SlaWarningEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderSlaBreachedEmailHtml(props: SlaBreachedEmailProps) {
|
||||||
|
return render(<SlaBreachedEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketCreatedEmailHtml(props: TicketCreatedEmailProps) {
|
||||||
|
return render(<TicketCreatedEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketResolvedEmailHtml(props: TicketResolvedEmailProps) {
|
||||||
|
return render(<TicketResolvedEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketAssignedEmailHtml(props: TicketAssignedEmailProps) {
|
||||||
|
return render(<TicketAssignedEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketStatusEmailHtml(props: TicketStatusEmailProps) {
|
||||||
|
return render(<TicketStatusEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderTicketCommentEmailHtml(props: TicketCommentEmailProps) {
|
||||||
|
return render(<TicketCommentEmail {...props} />, { pretty: false })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,18 @@ export type TicketCardData = {
|
||||||
assigneeName?: string | null
|
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) {
|
function badge(label: string, bg: string, color: string) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|
@ -76,7 +88,8 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketCard({ ticket }: { ticket: TicketCardData }) {
|
/** @deprecated Use TicketCard with props instead */
|
||||||
|
export function TicketCardLegacy({ ticket }: { ticket: TicketCardData }) {
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -100,3 +113,90 @@ export function TicketCard({ ticket }: { ticket: TicketCardData }) {
|
||||||
</Section>
|
</Section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #f1f5f9" }}>
|
||||||
|
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
|
||||||
|
Chamado #{ticketNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ margin: "4px 0 0 0", fontSize: "16px", fontWeight: 700, color: EMAIL_COLORS.textPrimary }}>
|
||||||
|
{ticketTitle}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
{status ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Status
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px" }}>{statusBadge(status)}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{priority ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Prioridade
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px" }}>{priorityBadge(priority)}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{categoryLabel ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Categoria
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{categoryLabel}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{companyName ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Empresa
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{companyName}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{requesterName ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ paddingBottom: "10px", width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Solicitante
|
||||||
|
</td>
|
||||||
|
<td style={{ paddingBottom: "10px", color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{requesterName}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{assigneeName ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ width: "100px", color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500 }}>
|
||||||
|
Responsavel
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>{assigneeName}</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
132
emails/invite-email.tsx
Normal file
132
emails/invite-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="Convite para o Sistema de Chamados" preview={`${inviterName} convidou voce para acessar o Sistema de Chamados Raven`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎉
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Voce foi convidado!
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
<strong>{inviterName}</strong> convidou voce para acessar o Sistema de Chamados Raven.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "100px" }}>
|
||||||
|
Funcao
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px", fontWeight: 600 }}>
|
||||||
|
{roleName}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{companyName ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "100px" }}>
|
||||||
|
Empresa
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
|
||||||
|
{companyName}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={inviteUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aceitar convite
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Se o botao nao funcionar, copie e cole esta URL no navegador:
|
||||||
|
<br />
|
||||||
|
<a href={inviteUrl} style={{ color: EMAIL_COLORS.primaryDark, textDecoration: "none" }}>
|
||||||
|
{inviteUrl}
|
||||||
|
</a>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={{ margin: "24px 0 0 0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
|
||||||
|
Este convite expira em 7 dias. Se voce nao esperava este convite, pode ignora-lo com seguranca.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
InviteEmail.PreviewProps = {
|
||||||
|
inviterName: "Renan Oliveira",
|
||||||
|
roleName: "Agente",
|
||||||
|
companyName: "Paulicon",
|
||||||
|
inviteUrl: "https://raven.rever.com.br/invite/abc123def456",
|
||||||
|
} satisfies InviteEmailProps
|
||||||
150
emails/new-login-email.tsx
Normal file
150
emails/new-login-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="Novo acesso detectado" preview="Detectamos um novo acesso a sua conta">
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#fef3c7",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #f59e0b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Novo acesso detectado
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
Detectamos um novo acesso a sua conta. Se foi voce, pode ignorar este e-mail.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Data/Hora
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
|
||||||
|
{formatDate(loginAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Endereco IP
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px", fontFamily: "monospace" }}>
|
||||||
|
{ipAddress}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{location ? (
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Localizacao
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "14px" }}>
|
||||||
|
{location}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textMuted, fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Dispositivo
|
||||||
|
</td>
|
||||||
|
<td style={{ color: EMAIL_COLORS.textPrimary, fontSize: "13px" }}>
|
||||||
|
{userAgent}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: "0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
|
||||||
|
Se voce nao reconhece este acesso, recomendamos que altere sua senha imediatamente.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NewLoginEmail.PreviewProps = {
|
||||||
|
loginAt: new Date().toISOString(),
|
||||||
|
ipAddress: "192.168.1.100",
|
||||||
|
userAgent: "Chrome 120.0 / Windows 11",
|
||||||
|
location: "Sao Paulo, SP, Brasil",
|
||||||
|
} satisfies NewLoginEmailProps
|
||||||
81
emails/password-reset-email.tsx
Normal file
81
emails/password-reset-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="Redefinicao de senha" preview="Voce solicitou a redefinicao de sua senha">
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#fef3c7",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #f59e0b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Redefinir senha
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
Recebemos uma solicitacao para redefinir a senha da sua conta. Clique no botao abaixo para criar uma nova senha.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={resetUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Redefinir senha
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Se o botao nao funcionar, copie e cole esta URL no navegador:
|
||||||
|
<br />
|
||||||
|
<a href={resetUrl} style={{ color: EMAIL_COLORS.primaryDark, textDecoration: "none" }}>
|
||||||
|
{resetUrl}
|
||||||
|
</a>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={{ margin: "24px 0 0 0", fontSize: "13px", color: EMAIL_COLORS.textMuted, textAlign: "center", lineHeight: "1.6" }}>
|
||||||
|
Este link expira em {expiresIn}. Se voce nao solicitou esta redefinicao, pode ignorar este e-mail com seguranca.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordResetEmail.PreviewProps = {
|
||||||
|
resetUrl: "https://raven.rever.com.br/redefinir-senha?token=abc123def456",
|
||||||
|
expiresIn: "1 hora",
|
||||||
|
} satisfies PasswordResetEmailProps
|
||||||
151
emails/sla-breached-email.tsx
Normal file
151
emails/sla-breached-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="SLA estourado" preview={`Chamado #${ticketNumber} estourou o SLA`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#fee2e2",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #ef4444",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🚨
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
SLA estourado
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
O chamado abaixo excedeu o tempo de atendimento acordado e requer atencao imediata.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fef2f2",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: "1px solid #fca5a5",
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fecaca" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Chamado
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#7f1d1d", fontSize: "14px", fontWeight: 600 }}>
|
||||||
|
#{ticketNumber}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fecaca" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Titulo
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#7f1d1d", fontSize: "14px" }}>
|
||||||
|
{ticketTitle}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#991b1b", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Estourado em
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#dc2626", fontSize: "14px", fontWeight: 700 }}>
|
||||||
|
{formatDate(breachedAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: "#dc2626",
|
||||||
|
color: "#ffffff",
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: "1px solid #b91c1c",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Atender agora
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Este chamado deve ser tratado com prioridade maxima.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SlaBreachedEmail.PreviewProps = {
|
||||||
|
ticketNumber: "41025",
|
||||||
|
ticketTitle: "Computador nao liga apos atualizacao",
|
||||||
|
breachedAt: new Date().toISOString(),
|
||||||
|
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
|
||||||
|
} satisfies SlaBreachedEmailProps
|
||||||
139
emails/sla-warning-email.tsx
Normal file
139
emails/sla-warning-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="Alerta de SLA" preview={`Chamado #${ticketNumber} esta proximo de estourar o SLA`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#fef3c7",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #f59e0b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Alerta de SLA
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
O chamado abaixo esta proximo de estourar o tempo de atendimento acordado.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fffbeb",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: "1px solid #fcd34d",
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fde68a" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Chamado
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#78350f", fontSize: "14px", fontWeight: 600 }}>
|
||||||
|
#{ticketNumber}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: "1px solid #fde68a" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Titulo
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#78350f", fontSize: "14px" }}>
|
||||||
|
{ticketTitle}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: "#92400e", fontSize: "13px", fontWeight: 500, width: "120px" }}>
|
||||||
|
Tempo restante
|
||||||
|
</td>
|
||||||
|
<td style={{ color: "#dc2626", fontSize: "14px", fontWeight: 700 }}>
|
||||||
|
{timeRemaining}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver chamado
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Acesse o sistema para mais detalhes e acompanhe o status do chamado.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SlaWarningEmail.PreviewProps = {
|
||||||
|
ticketNumber: "41025",
|
||||||
|
ticketTitle: "Computador nao liga apos atualizacao",
|
||||||
|
timeRemaining: "45 minutos",
|
||||||
|
ticketUrl: "https://raven.rever.com.br/tickets/abc123",
|
||||||
|
} satisfies SlaWarningEmailProps
|
||||||
82
emails/ticket-assigned-email.tsx
Normal file
82
emails/ticket-assigned-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="Chamado atribuido" preview={`Chamado #${ticketProps.ticketNumber} foi atribuido a ${assigneeName}`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#e0f2fe",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #0ea5e9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
👤
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Chamado atribuido
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
O chamado foi atribuido a <strong>{assigneeName}</strong>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TicketCard {...ticketProps} />
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver chamado
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Voce recebera atualizacoes por e-mail quando houver novidades.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
113
emails/ticket-comment-email.tsx
Normal file
113
emails/ticket-comment-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="Novo comentario" preview={`${commenterName} comentou no chamado #${ticketNumber}`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#e0f2fe",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #0ea5e9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
💬
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Novo comentario
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
<strong>{commenterName}</strong> comentou no chamado <strong>#{ticketNumber}</strong>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table cellPadding="0" cellSpacing="0" role="presentation" style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px", borderBottom: `1px solid #f1f5f9` }}>
|
||||||
|
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
|
||||||
|
Chamado #{ticketNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ margin: "4px 0 0 0", fontSize: "14px", fontWeight: 600, color: EMAIL_COLORS.textPrimary }}>
|
||||||
|
{ticketTitle}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "16px 20px" }}>
|
||||||
|
<Text style={{ margin: 0, fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
|
||||||
|
Comentario
|
||||||
|
</Text>
|
||||||
|
<Text style={{ margin: "8px 0 0 0", fontSize: "14px", lineHeight: "1.6", color: EMAIL_COLORS.textPrimary }}>
|
||||||
|
{commentPreview}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver e responder
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Clique no botao acima para ver o comentario completo e responder.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
80
emails/ticket-created-email.tsx
Normal file
80
emails/ticket-created-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="Novo chamado criado" preview={`Chamado #${ticketProps.ticketNumber} foi criado com sucesso`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#ecfdf5",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #10b981",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Chamado criado
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
Seu chamado foi registrado com sucesso e ja esta sendo processado pela nossa equipe.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TicketCard {...ticketProps} />
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Acompanhar chamado
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Voce recebera atualizacoes por e-mail quando houver novidades.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
121
emails/ticket-resolved-email.tsx
Normal file
121
emails/ticket-resolved-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="Chamado resolvido" preview={`Chamado #${ticketProps.ticketNumber} foi resolvido`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#ecfdf5",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #10b981",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎉
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Chamado resolvido
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
Seu chamado foi marcado como resolvido. Confira os detalhes abaixo.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TicketCard {...ticketProps} status="RESOLVED" />
|
||||||
|
|
||||||
|
{resolution ? (
|
||||||
|
<Section
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.border}`,
|
||||||
|
margin: "24px 0",
|
||||||
|
padding: "16px 20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ margin: "0 0 8px 0", fontSize: "13px", fontWeight: 600, color: EMAIL_COLORS.textMuted }}>
|
||||||
|
Resolucao
|
||||||
|
</Text>
|
||||||
|
<Text style={{ margin: 0, fontSize: "14px", lineHeight: "1.6", color: EMAIL_COLORS.textPrimary }}>
|
||||||
|
{resolution}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver detalhes
|
||||||
|
</Button>
|
||||||
|
{ratingUrl ? (
|
||||||
|
<Button
|
||||||
|
href={ratingUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
color: "#f8fafc",
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
marginLeft: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Avaliar atendimento
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Sua opiniao e importante! Avalie o atendimento para nos ajudar a melhorar.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
85
emails/ticket-status-email.tsx
Normal file
85
emails/ticket-status-email.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<RavenEmailLayout title="Status atualizado" preview={`Chamado #${ticketProps.ticketNumber} mudou de ${formatStatus(previousStatus)} para ${formatStatus(newStatus)}`}>
|
||||||
|
<Section style={{ textAlign: "center", margin: "24px 0" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: "64px",
|
||||||
|
height: "64px",
|
||||||
|
backgroundColor: "#e0f2fe",
|
||||||
|
borderRadius: "50%",
|
||||||
|
lineHeight: "64px",
|
||||||
|
fontSize: "28px",
|
||||||
|
border: "1px solid #0ea5e9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading style={{ margin: "0 0 12px 0", fontSize: "26px", fontWeight: 700, color: EMAIL_COLORS.textPrimary, textAlign: "center" }}>
|
||||||
|
Status atualizado
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text style={{ margin: "0 0 24px 0", fontSize: "15px", lineHeight: "1.7", color: EMAIL_COLORS.textSecondary, textAlign: "center" }}>
|
||||||
|
O status do seu chamado foi alterado de <strong>{formatStatus(previousStatus)}</strong> para <strong>{formatStatus(newStatus)}</strong>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TicketCard {...ticketProps} status={newStatus} />
|
||||||
|
|
||||||
|
<Section style={{ textAlign: "center", margin: "32px 0" }}>
|
||||||
|
<Button
|
||||||
|
href={ticketUrl}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundColor: EMAIL_COLORS.primary,
|
||||||
|
color: EMAIL_COLORS.primaryForeground,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "14px 24px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "14px",
|
||||||
|
border: `1px solid ${EMAIL_COLORS.primaryDark}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver chamado
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: EMAIL_COLORS.border, margin: "24px 0" }} />
|
||||||
|
|
||||||
|
<Text style={{ margin: 0, fontSize: "12px", color: EMAIL_COLORS.textMuted, textAlign: "center" }}>
|
||||||
|
Voce recebera atualizacoes por e-mail quando houver novidades.
|
||||||
|
</Text>
|
||||||
|
</RavenEmailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -10,7 +10,8 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
|
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
|
||||||
import { env } from "@/lib/env"
|
import { env } from "@/lib/env"
|
||||||
import { prisma } from "@/lib/prisma"
|
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 DEFAULT_EXPIRATION_DAYS = 7
|
||||||
const JSON_NULL = Prisma.JsonNull as Prisma.NullableJsonNullValueInput
|
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"
|
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
admin: "Administrador",
|
||||||
|
manager: "Gestor",
|
||||||
|
agent: "Agente",
|
||||||
|
collaborator: "Colaborador",
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoleName(role: string): string {
|
||||||
|
return ROLE_LABELS[role.toLowerCase()] ?? role
|
||||||
|
}
|
||||||
|
|
||||||
function generateToken() {
|
function generateToken() {
|
||||||
return randomBytes(32).toString("hex")
|
return randomBytes(32).toString("hex")
|
||||||
}
|
}
|
||||||
|
|
@ -213,5 +225,24 @@ export async function POST(request: Request) {
|
||||||
const normalized = buildInvitePayload(inviteWithEvents, now)
|
const normalized = buildInvitePayload(inviteWithEvents, now)
|
||||||
await syncInviteWithConvex(normalized)
|
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 })
|
return NextResponse.json({ invite: normalized })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const COLLABORATOR_VISIBLE_TYPES: NotificationType[] = [
|
||||||
"security_email_verify",
|
"security_email_verify",
|
||||||
"security_email_change",
|
"security_email_change",
|
||||||
"security_new_login",
|
"security_new_login",
|
||||||
|
"security_invite",
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function GET(_request: NextRequest) {
|
export async function GET(_request: NextRequest) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
|
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"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
|
@ -105,11 +105,24 @@ const ROLE_LABELS: Record<string, string> = {
|
||||||
machine: "Agente de dispositivo",
|
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) {
|
function formatRole(role: string) {
|
||||||
const key = role?.toLowerCase?.() ?? ""
|
const key = role?.toLowerCase?.() ?? ""
|
||||||
return ROLE_LABELS[key] ?? role
|
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) {
|
function formatMachinePersona(persona: string | null | undefined) {
|
||||||
const normalized = persona?.toLowerCase?.() ?? ""
|
const normalized = persona?.toLowerCase?.() ?? ""
|
||||||
if (normalized === "manager") return "Gestor"
|
if (normalized === "manager") return "Gestor"
|
||||||
|
|
@ -125,7 +138,6 @@ function machinePersonaBadgeVariant(persona: string | null | undefined) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_TABS: AdminUsersTab[] = ["team", "users", "invites"]
|
const ALL_TABS: AdminUsersTab[] = ["team", "users", "invites"]
|
||||||
const DEFAULT_KEEP_EMAILS = ["renan.pac@paulicon.com.br"]
|
|
||||||
|
|
||||||
// Tenant removido da UI (sem exibição)
|
// Tenant removido da UI (sem exibição)
|
||||||
|
|
||||||
|
|
@ -346,25 +358,6 @@ export function AdminUsersManager({
|
||||||
})
|
})
|
||||||
const [isCreatingUser, setIsCreatingUser] = useState(false)
|
const [isCreatingUser, setIsCreatingUser] = useState(false)
|
||||||
const [createPassword, setCreatePassword] = useState<string | null>(null)
|
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)
|
// Dispositivos (para listar vínculos por usuário)
|
||||||
type MachinesListItem = {
|
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>) {
|
async function handleAssignCompany(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const normalizedEmail = linkEmail.trim().toLowerCase()
|
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-sm font-semibold text-neutral-900">Equipe cadastrada</p>
|
||||||
<p className="text-xs text-neutral-500">{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "membro" : "membros"}</p>
|
<p className="text-xs text-neutral-500">{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "membro" : "membros"}</p>
|
||||||
</div>
|
</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">
|
||||||
<Button onClick={handleOpenCreateUser} size="sm" className="gap-2 self-start sm:self-auto">
|
<IconUserPlus className="size-4" />
|
||||||
<IconUserPlus className="size-4" />
|
Novo usuário
|
||||||
Novo usuário
|
</Button>
|
||||||
</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>
|
|
||||||
</div>
|
</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="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">
|
<div className="relative w-full md:max-w-sm">
|
||||||
|
|
@ -1205,6 +1146,7 @@ async function handleDeleteUser() {
|
||||||
placeholder="Todos os papéis"
|
placeholder="Todos os papéis"
|
||||||
searchPlaceholder="Buscar papel..."
|
searchPlaceholder="Buscar papel..."
|
||||||
className="md:w-48"
|
className="md:w-48"
|
||||||
|
triggerClassName="h-9 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<SearchableCombobox
|
<SearchableCombobox
|
||||||
value={teamCompanyFilter}
|
value={teamCompanyFilter}
|
||||||
|
|
@ -1213,6 +1155,7 @@ async function handleDeleteUser() {
|
||||||
placeholder="Todas as empresas"
|
placeholder="Todas as empresas"
|
||||||
searchPlaceholder="Buscar empresa..."
|
searchPlaceholder="Buscar empresa..."
|
||||||
className="md:w-56"
|
className="md:w-56"
|
||||||
|
triggerClassName="h-9 rounded-lg"
|
||||||
/>
|
/>
|
||||||
{/* Filtro por espaço removido */}
|
{/* Filtro por espaço removido */}
|
||||||
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
|
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
|
||||||
|
|
@ -1247,51 +1190,53 @@ async function handleDeleteUser() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="w-full overflow-x-auto">
|
<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-[900px] table-auto text-sm">
|
<Table className="min-w-[1100px] table-fixed text-sm">
|
||||||
<TableHeader className="bg-slate-100/80">
|
<TableHeader className="bg-slate-100/80">
|
||||||
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
<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
|
<Checkbox
|
||||||
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
||||||
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
|
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
|
||||||
aria-label="Selecionar todos"
|
aria-label="Selecionar todos"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-4">Nome</TableHead>
|
<TableHead className="w-[180px] px-3 text-center xl:border-l xl:border-slate-200">Nome</TableHead>
|
||||||
<TableHead className="px-4 md:w-72">E-mail</TableHead>
|
<TableHead className="w-[260px] px-3 text-center xl:border-l xl:border-slate-200">E-mail</TableHead>
|
||||||
<TableHead className="px-4 md:w-44">Papel</TableHead>
|
<TableHead className="w-[140px] px-3 text-center xl:border-l xl:border-slate-200">Papel</TableHead>
|
||||||
<TableHead className="px-4">Empresa</TableHead>
|
<TableHead className="w-[160px] px-3 text-center xl:border-l xl:border-slate-200">Empresa</TableHead>
|
||||||
<TableHead className="px-4">Criado em</TableHead>
|
<TableHead className="w-[180px] px-3 text-center xl:border-l xl:border-slate-200">Criado em</TableHead>
|
||||||
<TableHead className="px-4 text-right">Ações</TableHead>
|
<TableHead className="w-[100px] px-3 text-center xl:border-l xl:border-slate-200">Acoes</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="bg-white">
|
<TableBody className="bg-white">
|
||||||
{teamPaginated.length > 0 ? (
|
{teamPaginated.length > 0 ? (
|
||||||
teamPaginated.map((user) => (
|
teamPaginated.map((user) => (
|
||||||
<TableRow key={user.id} className="hover:bg-slate-100/70">
|
<TableRow key={user.id} className="hover:bg-slate-100/70">
|
||||||
<TableCell className="w-16 pl-6 pr-4">
|
<TableCell className="w-12 pl-4 pr-2">
|
||||||
<div className="flex items-center justify-start">
|
<Checkbox
|
||||||
<Checkbox
|
checked={teamSelection.has(user.id)}
|
||||||
checked={teamSelection.has(user.id)}
|
onCheckedChange={(checked) => {
|
||||||
onCheckedChange={(checked) => {
|
setTeamSelection((prev) => {
|
||||||
setTeamSelection((prev) => {
|
const next = new Set(prev)
|
||||||
const next = new Set(prev)
|
if (checked) next.add(user.id)
|
||||||
if (checked) next.add(user.id)
|
else next.delete(user.id)
|
||||||
else next.delete(user.id)
|
return next
|
||||||
return next
|
})
|
||||||
})
|
}}
|
||||||
}}
|
aria-label="Selecionar linha"
|
||||||
aria-label="Selecionar linha"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 font-medium text-neutral-800">{user.name || "—"}</TableCell>
|
<TableCell className="px-3 font-medium text-neutral-800 truncate">{user.name || "—"}</TableCell>
|
||||||
<TableCell className="px-4 text-neutral-600 break-words">{user.email}</TableCell>
|
<TableCell className="px-3 text-neutral-600 truncate">{user.email}</TableCell>
|
||||||
<TableCell className="px-4 text-neutral-600 whitespace-nowrap">{formatRole(user.role)}</TableCell>
|
<TableCell className="px-3">
|
||||||
<TableCell className="px-4 text-neutral-600">{user.companyName ?? "—"}</TableCell>
|
<Badge className={`rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap ${getRoleBadgeColor(user.role)}`}>
|
||||||
<TableCell className="px-4 text-neutral-500">{formatDate(user.createdAt)}</TableCell>
|
{formatRole(user.role)}
|
||||||
<TableCell className="px-4">
|
</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">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -1475,6 +1420,7 @@ async function handleDeleteUser() {
|
||||||
placeholder="Todos"
|
placeholder="Todos"
|
||||||
searchPlaceholder="Buscar tipo..."
|
searchPlaceholder="Buscar tipo..."
|
||||||
className="md:w-40"
|
className="md:w-40"
|
||||||
|
triggerClassName="h-9 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<SearchableCombobox
|
<SearchableCombobox
|
||||||
value={usersCompanyFilter}
|
value={usersCompanyFilter}
|
||||||
|
|
@ -1483,6 +1429,7 @@ async function handleDeleteUser() {
|
||||||
placeholder="Todas as empresas"
|
placeholder="Todas as empresas"
|
||||||
searchPlaceholder="Buscar empresa..."
|
searchPlaceholder="Buscar empresa..."
|
||||||
className="md:w-56"
|
className="md:w-56"
|
||||||
|
triggerClassName="h-9 rounded-lg"
|
||||||
/>
|
/>
|
||||||
{/* Filtro por espaço removido */}
|
{/* Filtro por espaço removido */}
|
||||||
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? (
|
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? (
|
||||||
|
|
@ -1517,7 +1464,7 @@ async function handleDeleteUser() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="w-full overflow-x-auto">
|
<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">
|
<Table className="min-w-[960px] table-fixed text-sm">
|
||||||
<TableHeader className="bg-slate-100/80">
|
<TableHeader className="bg-slate-100/80">
|
||||||
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
||||||
|
|
@ -1534,13 +1481,13 @@ async function handleDeleteUser() {
|
||||||
aria-label="Selecionar todos"
|
aria-label="Selecionar todos"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-4">Nome</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Nome</TableHead>
|
||||||
<TableHead className="px-4">E-mail</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">E-mail</TableHead>
|
||||||
<TableHead className="px-4">Tipo</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Tipo</TableHead>
|
||||||
<TableHead className="px-4">Perfil</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Perfil</TableHead>
|
||||||
<TableHead className="px-4">Empresa</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Empresa</TableHead>
|
||||||
<TableHead className="px-4">Criado em</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">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">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="bg-white">
|
<TableBody className="bg-white">
|
||||||
|
|
@ -1793,7 +1740,7 @@ async function handleDeleteUser() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="w-full overflow-x-auto">
|
<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">
|
<Table className="min-w-[800px] table-fixed text-sm">
|
||||||
<TableHeader className="bg-slate-100/80">
|
<TableHeader className="bg-slate-100/80">
|
||||||
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
||||||
|
|
@ -1804,11 +1751,11 @@ async function handleDeleteUser() {
|
||||||
aria-label="Selecionar todos"
|
aria-label="Selecionar todos"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-4">Colaborador</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Colaborador</TableHead>
|
||||||
<TableHead className="px-4">Papel</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Papel</TableHead>
|
||||||
<TableHead className="px-4">Expira em</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Expira em</TableHead>
|
||||||
<TableHead className="px-4">Status</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Status</TableHead>
|
||||||
<TableHead className="px-4 text-right">Ações</TableHead>
|
<TableHead className="px-4 text-center xl:border-l xl:border-slate-200">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="bg-white">
|
<TableBody className="bg-white">
|
||||||
|
|
@ -1961,49 +1908,6 @@ async function handleDeleteUser() {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
) : null}
|
) : null}
|
||||||
</Tabs>
|
</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
|
<Dialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
|
||||||
|
|
@ -788,11 +788,11 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
||||||
<Table className="w-full table-auto">
|
<Table className="w-full table-auto">
|
||||||
<TableHeader className="bg-slate-100/80 border-b border-slate-200">
|
<TableHeader className="bg-slate-100/80 border-b border-slate-200">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Empresa</TableHead>
|
<TableHead className="text-center">Empresa</TableHead>
|
||||||
<TableHead>Contratos ativos</TableHead>
|
<TableHead className="text-center">Contratos ativos</TableHead>
|
||||||
<TableHead>Contatos</TableHead>
|
<TableHead className="text-center">Contatos</TableHead>
|
||||||
<TableHead>Dispositivos</TableHead>
|
<TableHead className="text-center">Dispositivos</TableHead>
|
||||||
<TableHead className="text-right">Ações</TableHead>
|
<TableHead className="text-center">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
|
||||||
|
|
@ -227,10 +227,10 @@ function AccountsTable({
|
||||||
const effectiveTenantId = tenantId || DEFAULT_TENANT_ID
|
const effectiveTenantId = tenantId || DEFAULT_TENANT_ID
|
||||||
|
|
||||||
const headerCellClass =
|
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 =
|
const cellClass =
|
||||||
"px-3 py-4 text-sm text-neutral-700 first:pl-4 last:pr-4 whitespace-pre-wrap leading-snug"
|
"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 =
|
const metaLabelClass =
|
||||||
"text-[11px] font-semibold uppercase tracking-wide text-neutral-500"
|
"text-[11px] font-semibold uppercase tracking-wide text-neutral-500"
|
||||||
const metaValueClass = "text-sm text-neutral-600 leading-tight break-words"
|
const metaValueClass = "text-sm text-neutral-600 leading-tight break-words"
|
||||||
|
|
@ -689,10 +689,10 @@ function AccountsTable({
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base font-semibold">Usuários do cliente</CardTitle>
|
<CardTitle className="text-base font-semibold">Usuários</CardTitle>
|
||||||
<CardDescription>Gestores e colaboradores com acesso ao portal de chamados.</CardDescription>
|
<CardDescription>Gestores e colaboradores com acesso ao portal.</CardDescription>
|
||||||
</CardHeader>
|
</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-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="flex flex-1 flex-wrap gap-3">
|
<div className="flex flex-1 flex-wrap gap-3">
|
||||||
<div className="relative flex-1 min-w-[16rem]">
|
<div className="relative flex-1 min-w-[16rem]">
|
||||||
|
|
@ -718,6 +718,7 @@ function AccountsTable({
|
||||||
placeholder="Todos os papéis"
|
placeholder="Todos os papéis"
|
||||||
searchPlaceholder="Buscar papel..."
|
searchPlaceholder="Buscar papel..."
|
||||||
className="md:w-[12rem]"
|
className="md:w-[12rem]"
|
||||||
|
triggerClassName="h-9 rounded-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SearchableCombobox
|
<SearchableCombobox
|
||||||
|
|
@ -727,16 +728,18 @@ function AccountsTable({
|
||||||
placeholder="Todas as empresas"
|
placeholder="Todas as empresas"
|
||||||
searchPlaceholder="Buscar empresa..."
|
searchPlaceholder="Buscar empresa..."
|
||||||
className="md:w-[16rem]"
|
className="md:w-[16rem]"
|
||||||
|
triggerClassName="h-9 rounded-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<IconUserPlus className="size-4" />
|
||||||
Novo usuário
|
Novo usuário
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
disabled={selectedIds.length === 0 || isPending}
|
disabled={selectedIds.length === 0 || isPending}
|
||||||
onClick={() => openDeleteDialog(selectedIds)}
|
onClick={() => openDeleteDialog(selectedIds)}
|
||||||
>
|
>
|
||||||
|
|
@ -746,9 +749,9 @@ function AccountsTable({
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<TableRow className="bg-transparent">
|
||||||
<TableHead className="w-12 px-3">
|
<TableHead className="w-12 px-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -891,7 +894,7 @@ function AccountsTable({
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cn(cellClass, "align-middle text-center text-neutral-700")}>
|
<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]}
|
{ROLE_LABEL[account.role]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -910,17 +913,17 @@ function AccountsTable({
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={!account.authUserId || isPending}
|
disabled={!account.authUserId || isPending}
|
||||||
onClick={() => handleOpenEditor(account)}
|
onClick={() => handleOpenEditor(account)}
|
||||||
title="Editar usuário"
|
aria-label="Editar usuário"
|
||||||
>
|
>
|
||||||
<IconPencil className="size-4" />
|
<IconPencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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}
|
disabled={isPending}
|
||||||
onClick={() => openDeleteDialog([account.id])}
|
onClick={() => openDeleteDialog([account.id])}
|
||||||
title="Remover usuário"
|
aria-label="Remover usuário"
|
||||||
>
|
>
|
||||||
<IconTrash className="size-4" />
|
<IconTrash className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1050,10 +1053,8 @@ function AccountsTable({
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
options={editManagerOptions}
|
options={editManagerOptions}
|
||||||
placeholder="Sem gestor definido"
|
placeholder="Buscar gestor..."
|
||||||
searchPlaceholder="Buscar gestor..."
|
searchPlaceholder="Buscar gestor..."
|
||||||
allowClear
|
|
||||||
clearLabel="Remover gestor"
|
|
||||||
disabled={isSavingAccount}
|
disabled={isSavingAccount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1197,10 +1198,8 @@ function AccountsTable({
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
options={managerOptions}
|
options={managerOptions}
|
||||||
placeholder="Sem gestor definido"
|
placeholder="Buscar gestor..."
|
||||||
searchPlaceholder="Buscar gestor..."
|
searchPlaceholder="Buscar gestor..."
|
||||||
allowClear
|
|
||||||
clearLabel="Remover gestor"
|
|
||||||
disabled={isCreatingAccount}
|
disabled={isCreatingAccount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue