feat: link tickets in comments and align admin sidebars
This commit is contained in:
parent
c35eb673d3
commit
b0f57009ac
15 changed files with 1606 additions and 424 deletions
|
|
@ -53,6 +53,100 @@ function plainTextLength(html: string): number {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssigneeChangeComment(
|
||||||
|
reason: string,
|
||||||
|
context: { previousName: string; nextName: string },
|
||||||
|
): string {
|
||||||
|
const normalized = reason.replace(/\r\n/g, "\n").trim();
|
||||||
|
const lines = normalized
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
const previous = escapeHtml(context.previousName || "Não atribuído");
|
||||||
|
const next = escapeHtml(context.nextName || "Não atribuído");
|
||||||
|
const reasonHtml = lines.length
|
||||||
|
? lines.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
|
||||||
|
: `<p>—</p>`;
|
||||||
|
return `<p><strong>Responsável atualizado:</strong> ${previous} → ${next}</p><p><strong>Motivo da troca:</strong></p>${reasonHtml}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateSubject(subject: string) {
|
||||||
|
if (subject.length <= 60) return subject
|
||||||
|
return `${subject.slice(0, 57)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTicketMentionAnchor(ticket: Doc<"tickets">): string {
|
||||||
|
const reference = ticket.reference
|
||||||
|
const subject = escapeHtml(ticket.subject ?? "")
|
||||||
|
const truncated = truncateSubject(subject)
|
||||||
|
const status = (ticket.status ?? "PENDING").toString().toUpperCase()
|
||||||
|
const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase()
|
||||||
|
return `<a data-ticket-mention="true" data-ticket-id="${String(ticket._id)}" data-ticket-reference="${reference}" data-ticket-status="${status}" data-ticket-priority="${priority}" data-ticket-subject="${subject}" href="/tickets/${String(ticket._id)}" class="ticket-mention" title="Chamado #${reference}${subject ? ` • ${subject}` : ""}"><span class="ticket-mention-dot"></span><span class="ticket-mention-ref">#${reference}</span><span class="ticket-mention-sep">•</span><span class="ticket-mention-subject">${truncated}</span></a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) {
|
||||||
|
if (viewerRole === "ADMIN" || viewerRole === "AGENT") return true
|
||||||
|
if (viewerRole === "COLLABORATOR") {
|
||||||
|
return String(ticket.requesterId) === String(viewerId)
|
||||||
|
}
|
||||||
|
if (viewerRole === "MANAGER") {
|
||||||
|
// Gestores compartilham contexto interno; permitem apenas tickets da mesma empresa do solicitante
|
||||||
|
return String(ticket.requesterId) === String(viewerId)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeTicketMentions(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
html: string,
|
||||||
|
viewer: { user: Doc<"users">; role: string },
|
||||||
|
tenantId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!html || html.indexOf("data-ticket-mention") === -1) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionPattern = /<a\b[^>]*data-ticket-mention="true"[^>]*>[\s\S]*?<\/a>/gi
|
||||||
|
const matches = Array.from(html.matchAll(mentionPattern))
|
||||||
|
if (!matches.length) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = html
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const full = match[0]
|
||||||
|
const idMatch = /data-ticket-id="([^"]+)"/i.exec(full)
|
||||||
|
const ticketIdRaw = idMatch?.[1]
|
||||||
|
let replacement = ""
|
||||||
|
|
||||||
|
if (ticketIdRaw) {
|
||||||
|
const ticket = await ctx.db.get(ticketIdRaw as Id<"tickets">)
|
||||||
|
if (ticket && ticket.tenantId === tenantId && canMentionTicket(viewer.role, viewer.user._id, ticket)) {
|
||||||
|
replacement = buildTicketMentionAnchor(ticket)
|
||||||
|
} else {
|
||||||
|
const inner = match[0].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
|
||||||
|
replacement = escapeHtml(inner || `#${ticketIdRaw}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
replacement = escapeHtml(full.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
output = output.replace(full, replacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||||
if (!status) return "PENDING";
|
if (!status) return "PENDING";
|
||||||
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
|
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
|
||||||
|
|
@ -1170,11 +1264,6 @@ export const addComment = mutation({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyPlainLen = plainTextLength(args.body)
|
|
||||||
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
|
||||||
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorSnapshot: CommentAuthorSnapshot = {
|
const authorSnapshot: CommentAuthorSnapshot = {
|
||||||
name: author.name,
|
name: author.name,
|
||||||
email: author.email,
|
email: author.email,
|
||||||
|
|
@ -1182,12 +1271,18 @@ export const addComment = mutation({
|
||||||
teams: author.teams ?? undefined,
|
teams: author.teams ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizedBody = await normalizeTicketMentions(ctx, args.body, { user: author, role: normalizedRole }, ticketDoc.tenantId)
|
||||||
|
const bodyPlainLen = plainTextLength(normalizedBody)
|
||||||
|
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
||||||
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const id = await ctx.db.insert("ticketComments", {
|
const id = await ctx.db.insert("ticketComments", {
|
||||||
ticketId: args.ticketId,
|
ticketId: args.ticketId,
|
||||||
authorId: args.authorId,
|
authorId: args.authorId,
|
||||||
visibility: requestedVisibility,
|
visibility: requestedVisibility,
|
||||||
body: args.body,
|
body: normalizedBody,
|
||||||
authorSnapshot,
|
authorSnapshot,
|
||||||
attachments,
|
attachments,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|
@ -1240,14 +1335,15 @@ export const updateComment = mutation({
|
||||||
await requireTicketStaff(ctx, actorId, ticketDoc)
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyPlainLen = plainTextLength(body)
|
const normalizedBody = await normalizeTicketMentions(ctx, body, { user: actor, role: normalizedRole }, ticketDoc.tenantId)
|
||||||
|
const bodyPlainLen = plainTextLength(normalizedBody)
|
||||||
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
||||||
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(commentId, {
|
await ctx.db.patch(commentId, {
|
||||||
body,
|
body: normalizedBody,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1356,14 +1452,20 @@ export const updateStatus = mutation({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const changeAssignee = mutation({
|
export const changeAssignee = mutation({
|
||||||
args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") },
|
args: {
|
||||||
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
|
ticketId: v.id("tickets"),
|
||||||
|
assigneeId: v.id("users"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
reason: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => {
|
||||||
const ticket = await ctx.db.get(ticketId)
|
const ticket = await ctx.db.get(ticketId)
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
const ticketDoc = ticket as Doc<"tickets">
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
|
const viewerUser = viewer.user
|
||||||
const isAdmin = viewer.role === "ADMIN"
|
const isAdmin = viewer.role === "ADMIN"
|
||||||
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
|
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
|
||||||
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
|
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
|
||||||
|
|
@ -1381,6 +1483,26 @@ export const changeAssignee = mutation({
|
||||||
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
|
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedReason = reason.replace(/\r\n/g, "\n").trim()
|
||||||
|
if (normalizedReason.length < 5) {
|
||||||
|
throw new ConvexError("Informe um motivo para registrar a troca de responsável")
|
||||||
|
}
|
||||||
|
if (normalizedReason.length > 1000) {
|
||||||
|
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)")
|
||||||
|
}
|
||||||
|
const previousAssigneeName =
|
||||||
|
((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ??
|
||||||
|
"Não atribuído"
|
||||||
|
const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável"
|
||||||
|
const commentBody = buildAssigneeChangeComment(normalizedReason, {
|
||||||
|
previousName: previousAssigneeName,
|
||||||
|
nextName: nextAssigneeName,
|
||||||
|
})
|
||||||
|
const commentPlainLength = plainTextLength(commentBody)
|
||||||
|
if (commentPlainLength > MAX_COMMENT_CHARS) {
|
||||||
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const assigneeSnapshot = {
|
const assigneeSnapshot = {
|
||||||
name: assignee.name,
|
name: assignee.name,
|
||||||
|
|
@ -1392,9 +1514,39 @@ export const changeAssignee = mutation({
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId,
|
ticketId,
|
||||||
type: "ASSIGNEE_CHANGED",
|
type: "ASSIGNEE_CHANGED",
|
||||||
payload: { assigneeId, assigneeName: assignee.name, actorId },
|
payload: {
|
||||||
|
assigneeId,
|
||||||
|
assigneeName: assignee.name,
|
||||||
|
actorId,
|
||||||
|
previousAssigneeId: currentAssigneeId,
|
||||||
|
previousAssigneeName,
|
||||||
|
reason: normalizedReason,
|
||||||
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authorSnapshot: CommentAuthorSnapshot = {
|
||||||
|
name: viewerUser.name,
|
||||||
|
email: viewerUser.email,
|
||||||
|
avatarUrl: viewerUser.avatarUrl ?? undefined,
|
||||||
|
teams: viewerUser.teams ?? undefined,
|
||||||
|
}
|
||||||
|
await ctx.db.insert("ticketComments", {
|
||||||
|
ticketId,
|
||||||
|
authorId: actorId,
|
||||||
|
visibility: "INTERNAL",
|
||||||
|
body: commentBody,
|
||||||
|
authorSnapshot,
|
||||||
|
attachments: [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "COMMENT_ADDED",
|
||||||
|
payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl },
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
53
docs/propostas/melhoria-plataforma.md
Normal file
53
docs/propostas/melhoria-plataforma.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Propostas de Evolução da Plataforma de Chamados
|
||||||
|
|
||||||
|
## 1. Melhorias de Curto Prazo (baixo esforço)
|
||||||
|
|
||||||
|
- **Filtro unificado em todas as listas**
|
||||||
|
Criar um componente padrão com busca, chips de filtros ativos, botão de limpar e layout responsivo. Isso remove duplicidade nas páginas de Tickets, Admin ▸ Usuários, Empresas e melhora coerência visual.
|
||||||
|
|
||||||
|
- **Menções internas (@ticket e @usuário)**
|
||||||
|
Expandir o autocomplete recém-adicionado para permitir citar usuários internos relevantes (solicitantes ou responsáveis frequentes). Facilita handoffs e histórico contextual.
|
||||||
|
|
||||||
|
- **Barra de ações em massa consistente**
|
||||||
|
Padronizar cabeçalhos de tabelas (seleção em massa, contador, botões como “Excluir”, “Exportar”) com feedback visual claro (toasts + badges). Aplica-se a Usuários, Convites, Empresas etc.
|
||||||
|
|
||||||
|
- **Cards acionáveis no Dashboard**
|
||||||
|
Introduzir métricas com links para listas pré-filtradas (ex.: “6 tickets aguardando resposta”, “3 chamados com SLA a vencer hoje”) para navegação mais rápida.
|
||||||
|
|
||||||
|
- **Templates de filtro salvos**
|
||||||
|
Permitir que agentes salvem combinações frequentes (ex.: “Meus tickets urgentes”) e exibí-las como botões rápidos acima da tabela.
|
||||||
|
|
||||||
|
- **Uso do novo recurso de menção para relacionamentos**
|
||||||
|
Mostrar, no cabeçalho do ticket, os chamados relacionados via menção com contagem e navegação rápida.
|
||||||
|
|
||||||
|
- **Portal do cliente com resumo inteligente**
|
||||||
|
Para solicitantes, exibir timeline simplificada e botão “Abrir chamado relacionado” reutilizando o autocomplete, mas limitado aos chamados próprios.
|
||||||
|
|
||||||
|
## 2. Projetos de Médio Prazo
|
||||||
|
|
||||||
|
- **Repaginação dos filtros avançados**
|
||||||
|
Adotar um layout split (filtros à esquerda, resultados à direita), com árvore de categorias, sliders de data e componentes consistentes. Incluir colapsáveis para telas pequenas.
|
||||||
|
|
||||||
|
- **Automação simples no painel Admin**
|
||||||
|
Novo módulo “Automação” para regras básicas (ex.: “Se SLA crítico + sem responsável → notificar gestor e mover para fila X”). Iniciar com condições pré-definidas para reduzir esforço manual.
|
||||||
|
|
||||||
|
- **Painel de chamados linkados**
|
||||||
|
No detalhe do ticket, exibir seção “Chamados relacionados” com preview, status e atalhos, alimentada automaticamente pelas menções internas.
|
||||||
|
|
||||||
|
## 3. Iniciativas de Maior Impacto
|
||||||
|
|
||||||
|
- **Modo de trabalho focado para agentes**
|
||||||
|
Modo “foco” com navegação reduzida, atalhos (“Próximo ticket da minha fila”), indicadores de tempo e layout em painel único para reduzir alternância de contexto.
|
||||||
|
|
||||||
|
- **Integração com calendários externos**
|
||||||
|
Para tickets com datas de acompanhamento ou SLAs agendados, permitir agendar eventos no Google/Microsoft Calendar diretamente da plataforma.
|
||||||
|
|
||||||
|
- **Construtor de relatórios personalizados**
|
||||||
|
Ferramenta drag-and-drop que reutiliza métricas já disponíveis, permitindo salvar e compartilhar dashboards internos (ex.: equipe, gestor).
|
||||||
|
|
||||||
|
## Observações Gerais
|
||||||
|
|
||||||
|
- As propostas priorizam reutilizar componentes já existentes (RichTextEditor, API de menções) e alinhar elementos de UI (filtros, tabelas, sidebars).
|
||||||
|
- As iniciativas estão ordenadas do mais simples ao mais complexo, facilitando entregas incrementais.
|
||||||
|
- Os itens de médio e longo prazo podem ser fatiados em MVPs para garantir feedback rápido dos usuários internos.
|
||||||
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
const nextConfig = {}
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
turbopackFileSystemCacheForDev: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"@tabler/icons-react": "^3.35.0",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tiptap/extension-link": "^3.6.5",
|
"@tiptap/extension-link": "^3.6.5",
|
||||||
|
"@tiptap/extension-mention": "^3.6.5",
|
||||||
"@tiptap/extension-placeholder": "^3.6.5",
|
"@tiptap/extension-placeholder": "^3.6.5",
|
||||||
"@tiptap/react": "^3.6.5",
|
"@tiptap/react": "^3.6.5",
|
||||||
"@tiptap/starter-kit": "^3.6.5",
|
"@tiptap/starter-kit": "^3.6.5",
|
||||||
|
|
@ -67,6 +68,7 @@
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
"unicornstudio-react": "^1.4.31",
|
"unicornstudio-react": "^1.4.31",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.9"
|
"zod": "^4.1.9"
|
||||||
|
|
|
||||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
|
|
@ -89,6 +89,9 @@ importers:
|
||||||
'@tiptap/extension-link':
|
'@tiptap/extension-link':
|
||||||
specifier: ^3.6.5
|
specifier: ^3.6.5
|
||||||
version: 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
|
version: 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
|
||||||
|
'@tiptap/extension-mention':
|
||||||
|
specifier: ^3.6.5
|
||||||
|
version: 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)(@tiptap/suggestion@3.7.2(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
|
||||||
'@tiptap/extension-placeholder':
|
'@tiptap/extension-placeholder':
|
||||||
specifier: ^3.6.5
|
specifier: ^3.6.5
|
||||||
version: 3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
|
version: 3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
|
||||||
|
|
@ -155,6 +158,9 @@ importers:
|
||||||
three:
|
three:
|
||||||
specifier: ^0.180.0
|
specifier: ^0.180.0
|
||||||
version: 0.180.0
|
version: 0.180.0
|
||||||
|
tippy.js:
|
||||||
|
specifier: ^6.3.7
|
||||||
|
version: 6.3.7
|
||||||
unicornstudio-react:
|
unicornstudio-react:
|
||||||
specifier: ^1.4.31
|
specifier: ^1.4.31
|
||||||
version: 1.4.31(next@16.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.4.31(next@16.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
|
@ -1143,6 +1149,9 @@ packages:
|
||||||
'@polka/url@1.0.0-next.29':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
|
'@popperjs/core@2.11.8':
|
||||||
|
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||||
|
|
||||||
'@prisma/client@6.16.3':
|
'@prisma/client@6.16.3':
|
||||||
resolution: {integrity: sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==}
|
resolution: {integrity: sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
@ -2247,6 +2256,13 @@ packages:
|
||||||
'@tiptap/core': ^3.6.5
|
'@tiptap/core': ^3.6.5
|
||||||
'@tiptap/pm': ^3.6.5
|
'@tiptap/pm': ^3.6.5
|
||||||
|
|
||||||
|
'@tiptap/extension-mention@3.6.5':
|
||||||
|
resolution: {integrity: sha512-ACElkBvemEJGm8gVYI4QKjf6tfNj3m5dC9MkZL4rwZo4CAwjiNQ8oFhj1x7sPO1OVlnjt+FhnItBix5ztTF8Ng==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.6.5
|
||||||
|
'@tiptap/pm': ^3.6.5
|
||||||
|
'@tiptap/suggestion': ^3.6.5
|
||||||
|
|
||||||
'@tiptap/extension-ordered-list@3.6.5':
|
'@tiptap/extension-ordered-list@3.6.5':
|
||||||
resolution: {integrity: sha512-RiBl0Dkw8QtzS7OqUGm84BOyemw/N+hf8DYWsIqVysMRQAGBGhuklbw+DGpCL0nMHW4lh7WtvfKcb0yxLmhbbA==}
|
resolution: {integrity: sha512-RiBl0Dkw8QtzS7OqUGm84BOyemw/N+hf8DYWsIqVysMRQAGBGhuklbw+DGpCL0nMHW4lh7WtvfKcb0yxLmhbbA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -2299,6 +2315,12 @@ packages:
|
||||||
'@tiptap/starter-kit@3.6.5':
|
'@tiptap/starter-kit@3.6.5':
|
||||||
resolution: {integrity: sha512-LNAJQstB/VazmMlRbUyu3rCNVQ9af25Ywkn3Uyuwt3Ks9ZlliIm/x/zertdXTY2adoig+b36zT5Xcx1O4IdJ3A==}
|
resolution: {integrity: sha512-LNAJQstB/VazmMlRbUyu3rCNVQ9af25Ywkn3Uyuwt3Ks9ZlliIm/x/zertdXTY2adoig+b36zT5Xcx1O4IdJ3A==}
|
||||||
|
|
||||||
|
'@tiptap/suggestion@3.7.2':
|
||||||
|
resolution: {integrity: sha512-CYmIMeLqeGBotl7+4TrnGux/ov9IJoWTUQN/JcHp0aOoN3z8c/dQ6cziXXknr51jGHSdVYMWEyamLDZfcaGC1w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.7.2
|
||||||
|
'@tiptap/pm': ^3.7.2
|
||||||
|
|
||||||
'@tweenjs/tween.js@23.1.3':
|
'@tweenjs/tween.js@23.1.3':
|
||||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||||
|
|
||||||
|
|
@ -4676,6 +4698,9 @@ packages:
|
||||||
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tippy.js@6.3.7:
|
||||||
|
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
|
||||||
|
|
||||||
tldts-core@7.0.17:
|
tldts-core@7.0.17:
|
||||||
resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==}
|
resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==}
|
||||||
|
|
||||||
|
|
@ -5828,6 +5853,8 @@ snapshots:
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|
||||||
|
'@popperjs/core@2.11.8': {}
|
||||||
|
|
||||||
'@prisma/client@6.16.3(prisma@6.16.3(typescript@5.9.3))(typescript@5.9.3)':
|
'@prisma/client@6.16.3(prisma@6.16.3(typescript@5.9.3))(typescript@5.9.3)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
prisma: 6.16.3(typescript@5.9.3)
|
prisma: 6.16.3(typescript@5.9.3)
|
||||||
|
|
@ -6985,6 +7012,12 @@ snapshots:
|
||||||
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
|
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
|
||||||
'@tiptap/pm': 3.6.5
|
'@tiptap/pm': 3.6.5
|
||||||
|
|
||||||
|
'@tiptap/extension-mention@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)(@tiptap/suggestion@3.7.2(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
|
||||||
|
'@tiptap/pm': 3.6.5
|
||||||
|
'@tiptap/suggestion': 3.7.2(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
|
||||||
|
|
||||||
'@tiptap/extension-ordered-list@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
|
'@tiptap/extension-ordered-list@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
|
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
|
||||||
|
|
@ -7079,6 +7112,11 @@ snapshots:
|
||||||
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
|
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
|
||||||
'@tiptap/pm': 3.6.5
|
'@tiptap/pm': 3.6.5
|
||||||
|
|
||||||
|
'@tiptap/suggestion@3.7.2(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
|
||||||
|
'@tiptap/pm': 3.6.5
|
||||||
|
|
||||||
'@tweenjs/tween.js@23.1.3': {}
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
|
|
@ -9842,6 +9880,10 @@ snapshots:
|
||||||
|
|
||||||
tinyrainbow@3.0.3: {}
|
tinyrainbow@3.0.3: {}
|
||||||
|
|
||||||
|
tippy.js@6.3.7:
|
||||||
|
dependencies:
|
||||||
|
'@popperjs/core': 2.11.8
|
||||||
|
|
||||||
tldts-core@7.0.17: {}
|
tldts-core@7.0.17: {}
|
||||||
|
|
||||||
tldts@7.0.17:
|
tldts@7.0.17:
|
||||||
|
|
|
||||||
96
src/app/api/tickets/mentions/route.ts
Normal file
96
src/app/api/tickets/mentions/route.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
const MAX_RESULTS = 10
|
||||||
|
const MAX_SCAN = 60
|
||||||
|
|
||||||
|
function normalizeRole(role?: string | null) {
|
||||||
|
return (role ?? "").toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await assertAuthenticatedSession()
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ items: [] }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRole = normalizeRole(session.user.role)
|
||||||
|
const isAgentOrAdmin = normalizedRole === "admin" || normalizedRole === "agent"
|
||||||
|
const canLinkOwnTickets = normalizedRole === "collaborator"
|
||||||
|
|
||||||
|
if (!isAgentOrAdmin && !canLinkOwnTickets) {
|
||||||
|
return NextResponse.json({ items: [] }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const rawQuery = url.searchParams.get("q") ?? ""
|
||||||
|
const query = rawQuery.trim()
|
||||||
|
|
||||||
|
const whereBase: {
|
||||||
|
tenantId: string
|
||||||
|
requesterId?: string
|
||||||
|
} = { tenantId }
|
||||||
|
|
||||||
|
if (!isAgentOrAdmin) {
|
||||||
|
whereBase.requesterId = session.user.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericQuery = /^\d+$/.test(query)
|
||||||
|
|
||||||
|
const take = numericQuery ? MAX_RESULTS : MAX_SCAN
|
||||||
|
|
||||||
|
const tickets = await prisma.ticket.findMany({
|
||||||
|
where: whereBase,
|
||||||
|
include: {
|
||||||
|
assignee: { select: { name: true } },
|
||||||
|
requester: { select: { name: true } },
|
||||||
|
company: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take,
|
||||||
|
})
|
||||||
|
|
||||||
|
const lowered = query.toLowerCase()
|
||||||
|
|
||||||
|
const filtered = tickets
|
||||||
|
.filter((ticket) => {
|
||||||
|
if (!query) return true
|
||||||
|
const referenceMatch = String(ticket.reference).includes(query)
|
||||||
|
if (referenceMatch) return true
|
||||||
|
const subject = ticket.subject ?? ""
|
||||||
|
if (subject.toLowerCase().includes(lowered)) return true
|
||||||
|
const requesterName = ticket.requester?.name ?? ""
|
||||||
|
if (requesterName.toLowerCase().includes(lowered)) return true
|
||||||
|
const companyName = ticket.company?.name ?? ""
|
||||||
|
if (companyName.toLowerCase().includes(lowered)) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.slice(0, MAX_RESULTS)
|
||||||
|
|
||||||
|
const basePath = isAgentOrAdmin ? "/tickets" : "/portal/tickets"
|
||||||
|
|
||||||
|
const items = filtered.map((ticket) => {
|
||||||
|
const subject = ticket.subject ?? ""
|
||||||
|
return {
|
||||||
|
id: ticket.id,
|
||||||
|
reference: ticket.reference,
|
||||||
|
subject,
|
||||||
|
status: ticket.status,
|
||||||
|
priority: ticket.priority,
|
||||||
|
requesterName: ticket.requester?.name ?? null,
|
||||||
|
assigneeName: ticket.assignee?.name ?? null,
|
||||||
|
companyName: ticket.company?.name ?? null,
|
||||||
|
url: `${basePath}/${ticket.id}`,
|
||||||
|
updatedAt: ticket.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ items })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -160,6 +160,34 @@
|
||||||
}
|
}
|
||||||
.rich-text p { @apply my-2; }
|
.rich-text p { @apply my-2; }
|
||||||
.rich-text a { @apply text-neutral-900 underline; }
|
.rich-text a { @apply text-neutral-900 underline; }
|
||||||
|
.rich-text a[data-ticket-mention="true"],
|
||||||
|
.rich-text .ProseMirror a[data-ticket-mention="true"] {
|
||||||
|
@apply inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200;
|
||||||
|
}
|
||||||
|
.rich-text a[data-ticket-mention="true"] .ticket-mention-dot {
|
||||||
|
@apply inline-flex size-2 rounded-full bg-slate-400;
|
||||||
|
}
|
||||||
|
.rich-text a[data-ticket-mention="true"] .ticket-mention-ref {
|
||||||
|
@apply text-neutral-900;
|
||||||
|
}
|
||||||
|
.rich-text a[data-ticket-mention="true"] .ticket-mention-sep {
|
||||||
|
@apply text-neutral-400;
|
||||||
|
}
|
||||||
|
.rich-text a[data-ticket-mention="true"] .ticket-mention-subject {
|
||||||
|
@apply max-w-[220px] truncate text-neutral-700;
|
||||||
|
}
|
||||||
|
.rich-text a[data-ticket-mention="true"][data-ticket-status="PENDING"] .ticket-mention-dot {
|
||||||
|
@apply bg-amber-400;
|
||||||
|
}
|
||||||
|
.rich-text a[data-ticket-mention="true"][data-ticket-status="AWAITING_ATTENDANCE"] .ticket-mention-dot {
|
||||||
|
@apply bg-sky-500;
|
||||||
|
}
|
||||||
|
.rich-text a[data-ticket-mention="true"][data-ticket-status="PAUSED"] .ticket-mention-dot {
|
||||||
|
@apply bg-violet-500;
|
||||||
|
}
|
||||||
|
.rich-text a[data-ticket-mention="true"][data-ticket-status="RESOLVED"] .ticket-mention-dot {
|
||||||
|
@apply bg-emerald-500;
|
||||||
|
}
|
||||||
.rich-text ul { @apply my-2 list-disc ps-5; }
|
.rich-text ul { @apply my-2 list-disc ps-5; }
|
||||||
.rich-text ol { @apply my-2 list-decimal ps-5; }
|
.rich-text ol { @apply my-2 list-decimal ps-5; }
|
||||||
.rich-text li { @apply my-1; }
|
.rich-text li { @apply my-1; }
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
|
|
@ -356,6 +364,74 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
})
|
})
|
||||||
}, [combinedBaseUsers, usersSearch, usersCompanyFilter])
|
}, [combinedBaseUsers, usersSearch, usersCompanyFilter])
|
||||||
|
|
||||||
|
const [teamPageSize, setTeamPageSize] = useState<number>(10)
|
||||||
|
const [teamPageIndex, setTeamPageIndex] = useState<number>(0)
|
||||||
|
const teamTotal = filteredTeamUsers.length
|
||||||
|
const teamPageCount = Math.max(1, Math.ceil(teamTotal / teamPageSize))
|
||||||
|
const teamPaginated = useMemo(
|
||||||
|
() => filteredTeamUsers.slice(teamPageIndex * teamPageSize, teamPageIndex * teamPageSize + teamPageSize),
|
||||||
|
[filteredTeamUsers, teamPageIndex, teamPageSize]
|
||||||
|
)
|
||||||
|
const teamStart = teamTotal === 0 ? 0 : teamPageIndex * teamPageSize + 1
|
||||||
|
const teamEnd = teamTotal === 0 ? 0 : Math.min(teamTotal, teamPageIndex * teamPageSize + teamPageSize)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTeamPageIndex(0)
|
||||||
|
}, [teamSearch, teamRoleFilter, teamCompanyFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (teamPageIndex > teamPageCount - 1) {
|
||||||
|
setTeamPageIndex(Math.max(0, teamPageCount - 1))
|
||||||
|
}
|
||||||
|
}, [teamPageIndex, teamPageCount])
|
||||||
|
|
||||||
|
const [usersPageSize, setUsersPageSize] = useState<number>(10)
|
||||||
|
const [usersPageIndex, setUsersPageIndex] = useState<number>(0)
|
||||||
|
const usersTotal = filteredCombinedUsers.length
|
||||||
|
const usersPageCount = Math.max(1, Math.ceil(usersTotal / usersPageSize))
|
||||||
|
const usersPaginated = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredCombinedUsers.slice(
|
||||||
|
usersPageIndex * usersPageSize,
|
||||||
|
usersPageIndex * usersPageSize + usersPageSize
|
||||||
|
),
|
||||||
|
[filteredCombinedUsers, usersPageIndex, usersPageSize]
|
||||||
|
)
|
||||||
|
const usersStart = usersTotal === 0 ? 0 : usersPageIndex * usersPageSize + 1
|
||||||
|
const usersEnd = usersTotal === 0 ? 0 : Math.min(usersTotal, usersPageIndex * usersPageSize + usersPageSize)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUsersPageIndex(0)
|
||||||
|
}, [usersSearch, usersTypeFilter, usersCompanyFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (usersPageIndex > usersPageCount - 1) {
|
||||||
|
setUsersPageIndex(Math.max(0, usersPageCount - 1))
|
||||||
|
}
|
||||||
|
}, [usersPageIndex, usersPageCount])
|
||||||
|
|
||||||
|
const [invitesPageSize, setInvitesPageSize] = useState<number>(10)
|
||||||
|
const [invitesPageIndex, setInvitesPageIndex] = useState<number>(0)
|
||||||
|
const invitesTotal = invites.length
|
||||||
|
const invitesPageCount = Math.max(1, Math.ceil(invitesTotal / invitesPageSize))
|
||||||
|
const paginatedInvites = useMemo(
|
||||||
|
() => invites.slice(invitesPageIndex * invitesPageSize, invitesPageIndex * invitesPageSize + invitesPageSize),
|
||||||
|
[invites, invitesPageIndex, invitesPageSize]
|
||||||
|
)
|
||||||
|
const invitesStart = invitesTotal === 0 ? 0 : invitesPageIndex * invitesPageSize + 1
|
||||||
|
const invitesEnd =
|
||||||
|
invitesTotal === 0 ? 0 : Math.min(invitesTotal, invitesPageIndex * invitesPageSize + invitesPageSize)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInvitesPageIndex(0)
|
||||||
|
}, [invitesTotal])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (invitesPageIndex > invitesPageCount - 1) {
|
||||||
|
setInvitesPageIndex(Math.max(0, invitesPageCount - 1))
|
||||||
|
}
|
||||||
|
}, [invitesPageIndex, invitesPageCount])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1008,11 +1084,13 @@ async function handleDeleteUser() {
|
||||||
<CardTitle>Equipe cadastrada</CardTitle>
|
<CardTitle>Equipe cadastrada</CardTitle>
|
||||||
<CardDescription>Usuários com acesso ao sistema. Edite papéis, dados pessoais ou gere uma nova senha.</CardDescription>
|
<CardDescription>Usuários com acesso ao sistema. Edite papéis, dados pessoais ou gere uma nova senha.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="overflow-x-auto">
|
<CardContent className="space-y-4">
|
||||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
<div className="w-full overflow-x-auto">
|
||||||
<thead>
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
<table className="min-w-full table-fixed text-sm">
|
||||||
<th className="w-10 py-3 pr-2 font-medium">
|
<thead className="bg-slate-100/80">
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
|
||||||
|
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
||||||
|
|
@ -1021,20 +1099,20 @@ async function handleDeleteUser() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
|
||||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
|
||||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
|
||||||
<th className="py-3 pr-4 font-medium">Empresa</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
|
||||||
<th className="py-3 pr-4 font-medium">Máquinas</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">Máquinas</th>
|
||||||
{/* Espaço removido */}
|
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
|
||||||
<th className="py-3 pr-4 font-medium">Criado em</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
||||||
<th className="py-3 font-medium">Ações</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100 bg-white">
|
||||||
{filteredTeamUsers.map((user) => (
|
{teamPaginated.length > 0 ? (
|
||||||
<tr key={user.id} className="hover:bg-slate-50">
|
teamPaginated.map((user) => (
|
||||||
<td className="py-3 pr-2">
|
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
|
||||||
|
<td className="px-4 py-3 first:pl-6">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={teamSelection.has(user.id)}
|
checked={teamSelection.has(user.id)}
|
||||||
|
|
@ -1050,23 +1128,27 @@ async function handleDeleteUser() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">{user.name || "—"}</td>
|
||||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
||||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
|
<td className="px-4 py-3 text-neutral-600">{formatRole(user.role)}</td>
|
||||||
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
|
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||||
<td className="py-3 pr-4 text-neutral-600">
|
<td className="px-4 py-3 text-neutral-600">
|
||||||
{(() => {
|
{(() => {
|
||||||
const list = machinesByUserEmail.get((user.email ?? '').toLowerCase()) ?? []
|
const list = machinesByUserEmail.get((user.email ?? "").toLowerCase()) ?? []
|
||||||
if (list.length === 0) return '—'
|
if (list.length === 0) return "—"
|
||||||
const names = list.map((m) => m.hostname || m.id)
|
const names = list.map((m) => m.hostname || m.id)
|
||||||
const head = names.slice(0, 2).join(', ')
|
const head = names.slice(0, 2).join(", ")
|
||||||
const extra = names.length > 2 ? ` +${names.length - 2}` : ''
|
const extra = names.length > 2 ? ` +${names.length - 2}` : ""
|
||||||
return <span className="text-xs font-medium" title={names.join(', ')}>{head}{extra}</span>
|
return (
|
||||||
|
<span className="text-xs font-medium" title={names.join(", ")}>
|
||||||
|
{head}
|
||||||
|
{extra}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
{/* Espaço removido */}
|
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
<td className="px-4 py-3 last:pr-6">
|
||||||
<td className="py-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -1094,18 +1176,76 @@ async function handleDeleteUser() {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
{filteredTeamUsers.length === 0 ? (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-6 text-center text-neutral-500">
|
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||||
{teamUsers.length === 0
|
{teamUsers.length === 0
|
||||||
? "Nenhum usuário cadastrado até o momento."
|
? "Nenhum usuário cadastrado até o momento."
|
||||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
||||||
|
<div>{teamTotal === 0 ? "Nenhum registro" : `Mostrando ${teamStart}-${teamEnd} de ${teamTotal}`}</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm">
|
||||||
|
<span>Itens por página</span>
|
||||||
|
<Select
|
||||||
|
value={`${teamPageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const next = Number(value)
|
||||||
|
setTeamPageSize(next)
|
||||||
|
setTeamPageIndex(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-20">
|
||||||
|
<SelectValue placeholder={`${teamPageSize}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="end">
|
||||||
|
{[10, 20, 30, 50].map((n) => (
|
||||||
|
<SelectItem key={`team-page-${n}`} value={`${n}`}>
|
||||||
|
{n}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
disabled={teamPageIndex === 0}
|
||||||
|
onClick={() => setTeamPageIndex((previous) => Math.max(0, previous - 1))}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
isActive
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{teamPageIndex + 1}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
disabled={teamPageIndex >= teamPageCount - 1}
|
||||||
|
onClick={() =>
|
||||||
|
setTeamPageIndex((previous) => Math.min(teamPageCount - 1, previous + 1))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -1226,33 +1366,42 @@ async function handleDeleteUser() {
|
||||||
<CardTitle>Usuários</CardTitle>
|
<CardTitle>Usuários</CardTitle>
|
||||||
<CardDescription>Pessoas e máquinas com acesso ao sistema.</CardDescription>
|
<CardDescription>Pessoas e máquinas com acesso ao sistema.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="overflow-x-auto">
|
<CardContent className="space-y-4">
|
||||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
<div className="w-full overflow-x-auto">
|
||||||
<thead>
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
<table className="min-w-full table-fixed text-sm">
|
||||||
<th className="w-10 py-3 pr-2 font-medium">
|
<thead className="bg-slate-100/80">
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
|
||||||
|
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={usersSelection.size > 0 && usersSelection.size === filteredCombinedUsers.length || (usersSelection.size > 0 && usersSelection.size < filteredCombinedUsers.length && "indeterminate")}
|
checked={
|
||||||
|
usersSelection.size > 0 &&
|
||||||
|
usersSelection.size === filteredCombinedUsers.length
|
||||||
|
? true
|
||||||
|
: usersSelection.size > 0
|
||||||
|
? "indeterminate"
|
||||||
|
: false
|
||||||
|
}
|
||||||
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
|
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
|
||||||
aria-label="Selecionar todos"
|
aria-label="Selecionar todos"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
|
||||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
|
||||||
<th className="py-3 pr-4 font-medium">Tipo</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">Tipo</th>
|
||||||
<th className="py-3 pr-4 font-medium">Perfil</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">Perfil</th>
|
||||||
<th className="py-3 pr-4 font-medium">Empresa</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
|
||||||
{/* Espaço removido */}
|
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
|
||||||
<th className="py-3 pr-4 font-medium">Criado em</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
||||||
<th className="py-3 font-medium">Ações</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100 bg-white">
|
||||||
{filteredCombinedUsers.map((user) => (
|
{usersPaginated.length > 0 ? (
|
||||||
<tr key={user.id} className="hover:bg-slate-50">
|
usersPaginated.map((user) => (
|
||||||
<td className="py-3 pr-2">
|
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
|
||||||
|
<td className="px-4 py-3 first:pl-6">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={usersSelection.has(user.id)}
|
checked={usersSelection.has(user.id)}
|
||||||
|
|
@ -1268,13 +1417,20 @@ async function handleDeleteUser() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || (user.role === "machine" ? "Máquina" : "—")}</td>
|
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">
|
||||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
{user.name || (user.role === "machine" ? "Máquina" : "—")}
|
||||||
<td className="py-3 pr-4 text-neutral-600">{user.role === "machine" ? "Máquina" : "Pessoa"}</td>
|
</td>
|
||||||
<td className="py-3 pr-4 text-neutral-600">
|
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">
|
||||||
|
{user.role === "machine" ? "Máquina" : "Pessoa"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">
|
||||||
{user.role === "machine" ? (
|
{user.role === "machine" ? (
|
||||||
user.machinePersona ? (
|
user.machinePersona ? (
|
||||||
<Badge variant={machinePersonaBadgeVariant(user.machinePersona)} className="rounded-full px-3 py-1 text-xs font-medium">
|
<Badge
|
||||||
|
variant={machinePersonaBadgeVariant(user.machinePersona)}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||||
|
>
|
||||||
{formatMachinePersona(user.machinePersona)}
|
{formatMachinePersona(user.machinePersona)}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1284,10 +1440,9 @@ async function handleDeleteUser() {
|
||||||
formatRole(user.role)
|
formatRole(user.role)
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
|
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||||
{/* Espaço removido */}
|
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
<td className="px-4 py-3 last:pr-6">
|
||||||
<td className="py-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -1302,7 +1457,15 @@ async function handleDeleteUser() {
|
||||||
</Button>
|
</Button>
|
||||||
{user.role === "machine" ? (
|
{user.role === "machine" ? (
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href={(extractMachineId(user.email) ? `/admin/machines/${extractMachineId(user.email)}` : "/admin/machines")}>Detalhes da máquina</Link>
|
<Link
|
||||||
|
href={
|
||||||
|
extractMachineId(user.email)
|
||||||
|
? `/admin/machines/${extractMachineId(user.email)}`
|
||||||
|
: "/admin/machines"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Detalhes da máquina
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1320,18 +1483,76 @@ async function handleDeleteUser() {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
{filteredCombinedUsers.length === 0 ? (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="py-6 text-center text-neutral-500">
|
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||||
{combinedBaseUsers.length === 0
|
{combinedBaseUsers.length === 0
|
||||||
? "Nenhum usuário cadastrado até o momento."
|
? "Nenhum usuário cadastrado até o momento."
|
||||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
||||||
|
<div>{usersTotal === 0 ? "Nenhum registro" : `Mostrando ${usersStart}-${usersEnd} de ${usersTotal}`}</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm">
|
||||||
|
<span>Itens por página</span>
|
||||||
|
<Select
|
||||||
|
value={`${usersPageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const next = Number(value)
|
||||||
|
setUsersPageSize(next)
|
||||||
|
setUsersPageIndex(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-20">
|
||||||
|
<SelectValue placeholder={`${usersPageSize}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="end">
|
||||||
|
{[10, 20, 30, 50].map((n) => (
|
||||||
|
<SelectItem key={`users-page-${n}`} value={`${n}`}>
|
||||||
|
{n}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
disabled={usersPageIndex === 0}
|
||||||
|
onClick={() => setUsersPageIndex((previous) => Math.max(0, previous - 1))}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
isActive
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{usersPageIndex + 1}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
disabled={usersPageIndex >= usersPageCount - 1}
|
||||||
|
onClick={() =>
|
||||||
|
setUsersPageIndex((previous) => Math.min(usersPageCount - 1, previous + 1))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -1422,11 +1643,13 @@ async function handleDeleteUser() {
|
||||||
<CardTitle>Convites emitidos</CardTitle>
|
<CardTitle>Convites emitidos</CardTitle>
|
||||||
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
|
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="overflow-x-auto">
|
<CardContent className="space-y-4">
|
||||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
<div className="w-full overflow-x-auto">
|
||||||
<thead>
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
<table className="min-w-full table-fixed text-sm">
|
||||||
<th className="w-10 py-3 pr-2 font-medium">
|
<thead className="bg-slate-100/80">
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
|
||||||
|
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
|
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
|
||||||
|
|
@ -1435,18 +1658,18 @@ async function handleDeleteUser() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="py-3 pr-4 font-medium">Colaborador</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Colaborador</th>
|
||||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
|
||||||
{/* Espaço removido */}
|
<th className="px-4 py-3 font-semibold text-neutral-600">Expira em</th>
|
||||||
<th className="py-3 pr-4 font-medium">Expira em</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600">Status</th>
|
||||||
<th className="py-3 pr-4 font-medium">Status</th>
|
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
||||||
<th className="py-3 font-medium">Ações</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100 bg-white">
|
||||||
{invites.map((invite) => (
|
{paginatedInvites.length > 0 ? (
|
||||||
<tr key={invite.id} className="hover:bg-slate-50">
|
paginatedInvites.map((invite) => (
|
||||||
<td className="py-3 pr-2">
|
<tr key={invite.id} className="transition-colors hover:bg-slate-50/80">
|
||||||
|
<td className="px-4 py-3 first:pl-6">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={inviteSelection.has(invite.id)}
|
checked={inviteSelection.has(invite.id)}
|
||||||
|
|
@ -1462,16 +1685,15 @@ async function handleDeleteUser() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4">
|
<td className="px-4 py-3 first:pl-6">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
||||||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
|
<td className="px-4 py-3 text-neutral-600">{formatRole(invite.role)}</td>
|
||||||
{/* Espaço removido */}
|
<td className="px-4 py-3 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||||
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
<td className="px-4 py-3">
|
||||||
<td className="py-3 pr-4">
|
|
||||||
<Badge
|
<Badge
|
||||||
variant={inviteStatusVariant(invite.status)}
|
variant={inviteStatusVariant(invite.status)}
|
||||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||||
|
|
@ -1479,7 +1701,7 @@ async function handleDeleteUser() {
|
||||||
{formatStatus(invite.status)}
|
{formatStatus(invite.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3">
|
<td className="px-4 py-3 last:pr-6">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||||
Copiar link
|
Copiar link
|
||||||
|
|
@ -1509,17 +1731,21 @@ async function handleDeleteUser() {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
{invites.length === 0 ? (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-6 text-center text-neutral-500">
|
<td colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||||
Nenhum convite emitido até o momento.
|
Nenhum convite emitido até o momento.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="mt-3 flex items-center justify-end">
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
||||||
|
<div>{invitesTotal === 0 ? "Nenhum registro" : `Mostrando ${invitesStart}-${invitesEnd} de ${invitesTotal}`}</div>
|
||||||
|
<div className="flex flex-col-reverse gap-3 md:flex-row md:items-center md:gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1529,6 +1755,60 @@ async function handleDeleteUser() {
|
||||||
>
|
>
|
||||||
<IconTrash className="size-4" /> Revogar selecionados
|
<IconTrash className="size-4" /> Revogar selecionados
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm">
|
||||||
|
<span>Itens por página</span>
|
||||||
|
<Select
|
||||||
|
value={`${invitesPageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const next = Number(value)
|
||||||
|
setInvitesPageSize(next)
|
||||||
|
setInvitesPageIndex(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-20">
|
||||||
|
<SelectValue placeholder={`${invitesPageSize}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="end">
|
||||||
|
{[10, 20, 30, 50].map((n) => (
|
||||||
|
<SelectItem key={`invites-page-${n}`} value={`${n}`}>
|
||||||
|
{n}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
disabled={invitesPageIndex === 0}
|
||||||
|
onClick={() => setInvitesPageIndex((previous) => Math.max(0, previous - 1))}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
isActive
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{invitesPageIndex + 1}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
disabled={invitesPageIndex >= invitesPageCount - 1}
|
||||||
|
onClick={() =>
|
||||||
|
setInvitesPageIndex((previous) => Math.min(invitesPageCount - 1, previous + 1))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -758,7 +758,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<Table className="w-full table-auto">
|
<Table className="w-full table-auto">
|
||||||
<TableHeader className="bg-muted border-b">
|
<TableHeader className="bg-slate-100/80 border-b border-slate-200">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Empresa</TableHead>
|
<TableHead>Empresa</TableHead>
|
||||||
<TableHead>Contratos ativos</TableHead>
|
<TableHead>Contratos ativos</TableHead>
|
||||||
|
|
@ -787,7 +787,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
||||||
return (
|
return (
|
||||||
<TableRow key={company.id} className="hover:bg-muted/40">
|
<TableRow key={company.id} className="hover:bg-muted/40">
|
||||||
<TableCell className="align-middle">
|
<TableCell className="align-middle">
|
||||||
<div className="space-y-1">
|
<div className="flex h-full flex-col justify-center gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-semibold text-foreground">{company.name}</p>
|
<p className="font-semibold text-foreground">{company.name}</p>
|
||||||
{company.isAvulso ? <Badge variant="outline">Avulso</Badge> : null}
|
{company.isAvulso ? <Badge variant="outline">Avulso</Badge> : null}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ const ROLE_LABEL: Record<AdminAccount["role"], string> = {
|
||||||
COLLABORATOR: "Colaborador",
|
COLLABORATOR: "Colaborador",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_CONTACT_VALUE = "__none__"
|
||||||
|
|
||||||
function createId(prefix: string) {
|
function createId(prefix: string) {
|
||||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
return `${prefix}-${crypto.randomUUID()}`
|
return `${prefix}-${crypto.randomUUID()}`
|
||||||
|
|
@ -612,16 +614,16 @@ function CompanySectionSheet({ editor, onClose, onUpdated }: CompanySectionSheet
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
<Sheet open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
||||||
<SheetContent className="flex w-full max-w-4xl flex-col gap-0 p-0">
|
<SheetContent className="flex w-full max-w-none flex-col gap-0 bg-background p-0 sm:max-w-[48rem] lg:max-w-[60rem] xl:max-w-[68rem]">
|
||||||
<SheetHeader className="border-b border-border/60 px-6 py-4">
|
<SheetHeader className="border-b border-border/60 px-10 py-7">
|
||||||
<SheetTitle className="text-base font-semibold">
|
<SheetTitle className="text-xl font-semibold">
|
||||||
{editor?.section === "contacts" ? "Contatos da empresa" : null}
|
{editor?.section === "contacts" ? "Contatos da empresa" : null}
|
||||||
{editor?.section === "locations" ? "Localizações e unidades" : null}
|
{editor?.section === "locations" ? "Localizações e unidades" : null}
|
||||||
{editor?.section === "contracts" ? "Contratos ativos" : null}
|
{editor?.section === "contracts" ? "Contratos ativos" : null}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-6">{content}</div>
|
<div className="flex-1 overflow-y-auto px-10 py-8">{content}</div>
|
||||||
<SheetFooter className="border-t border-border/60 px-6 py-4">
|
<SheetFooter className="border-t border-border/60 px-10 py-5">
|
||||||
<div className="flex w-full items-center justify-end">
|
<div className="flex w-full items-center justify-end">
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<span className="text-sm text-muted-foreground">Salvando...</span>
|
<span className="text-sm text-muted-foreground">Salvando...</span>
|
||||||
|
|
@ -674,11 +676,11 @@ function ContactsEditor({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={submit} className="space-y-5">
|
<form onSubmit={submit} className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<p className="text-sm font-semibold text-foreground">Contatos estratégicos</p>
|
<p className="text-base font-semibold text-foreground">Contatos estratégicos</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Cadastre responsáveis por aprovação, faturamento e comunicação.
|
Cadastre responsáveis por aprovação, faturamento e comunicação.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -686,6 +688,7 @@ function ContactsEditor({
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="self-start sm:self-auto"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
fieldArray.append({
|
fieldArray.append({
|
||||||
id: createId("contact"),
|
id: createId("contact"),
|
||||||
|
|
@ -703,18 +706,18 @@ function ContactsEditor({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconPlus className="mr-1 size-3.5" />
|
<IconPlus className="mr-2 size-4" />
|
||||||
Novo contato
|
Novo contato
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{fieldArray.fields.map((field, index) => {
|
{fieldArray.fields.map((field, index) => {
|
||||||
const errors = form.formState.errors.contacts?.[index]
|
const errors = form.formState.errors.contacts?.[index]
|
||||||
return (
|
return (
|
||||||
<Card key={field.id}>
|
<Card key={field.id} className="border border-border/60 shadow-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||||
<CardTitle className="text-base font-semibold">Contato #{index + 1}</CardTitle>
|
<CardTitle className="text-lg font-semibold">Contato #{index + 1}</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -725,29 +728,39 @@ function ContactsEditor({
|
||||||
<IconTrash className="size-4" />
|
<IconTrash className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Nome completo</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Nome completo
|
||||||
|
</Label>
|
||||||
<Input {...form.register(`contacts.${index}.fullName` as const)} />
|
<Input {...form.register(`contacts.${index}.fullName` as const)} />
|
||||||
<FieldError message={errors?.fullName?.message} />
|
<FieldError message={errors?.fullName?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>E-mail</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
E-mail
|
||||||
|
</Label>
|
||||||
<Input {...form.register(`contacts.${index}.email` as const)} />
|
<Input {...form.register(`contacts.${index}.email` as const)} />
|
||||||
<FieldError message={errors?.email?.message} />
|
<FieldError message={errors?.email?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Telefone</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Telefone
|
||||||
|
</Label>
|
||||||
<Input {...form.register(`contacts.${index}.phone` as const)} placeholder="(11) 99999-0000" />
|
<Input {...form.register(`contacts.${index}.phone` as const)} placeholder="(11) 99999-0000" />
|
||||||
<FieldError message={errors?.phone?.message as string | undefined} />
|
<FieldError message={errors?.phone?.message as string | undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>WhatsApp</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
WhatsApp
|
||||||
|
</Label>
|
||||||
<Input {...form.register(`contacts.${index}.whatsapp` as const)} placeholder="(11) 99999-0000" />
|
<Input {...form.register(`contacts.${index}.whatsapp` as const)} placeholder="(11) 99999-0000" />
|
||||||
<FieldError message={errors?.whatsapp?.message as string | undefined} />
|
<FieldError message={errors?.whatsapp?.message as string | undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Função</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Função
|
||||||
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name={`contacts.${index}.role` as const}
|
name={`contacts.${index}.role` as const}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
@ -767,12 +780,16 @@ function ContactsEditor({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Cargo interno</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Cargo interno
|
||||||
|
</Label>
|
||||||
<Input {...form.register(`contacts.${index}.title` as const)} placeholder="ex.: Coordenador TI" />
|
<Input {...form.register(`contacts.${index}.title` as const)} placeholder="ex.: Coordenador TI" />
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2 space-y-2">
|
||||||
<Label>Preferências de contato</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Preferências de contato
|
||||||
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name={`contacts.${index}.preference` as const}
|
name={`contacts.${index}.preference` as const}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
@ -785,26 +802,28 @@ function ContactsEditor({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="md:col-span-2 flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={form.watch(`contacts.${index}.canAuthorizeTickets` as const)}
|
checked={form.watch(`contacts.${index}.canAuthorizeTickets` as const)}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
form.setValue(`contacts.${index}.canAuthorizeTickets` as const, Boolean(checked))
|
form.setValue(`contacts.${index}.canAuthorizeTickets` as const, Boolean(checked))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">Pode autorizar tickets</span>
|
<span className="text-sm font-medium text-neutral-700">Pode autorizar tickets</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="md:col-span-2 flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={form.watch(`contacts.${index}.canApproveCosts` as const)}
|
checked={form.watch(`contacts.${index}.canApproveCosts` as const)}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
form.setValue(`contacts.${index}.canApproveCosts` as const, Boolean(checked))
|
form.setValue(`contacts.${index}.canApproveCosts` as const, Boolean(checked))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">Pode aprovar custos</span>
|
<span className="text-sm font-medium text-neutral-700">Pode aprovar custos</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2 space-y-2">
|
||||||
<Label>Anotações</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Anotações
|
||||||
|
</Label>
|
||||||
<Textarea {...form.register(`contacts.${index}.notes` as const)} />
|
<Textarea {...form.register(`contacts.${index}.notes` as const)} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -813,11 +832,11 @@ function ContactsEditor({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">
|
<Button type="submit" className="whitespace-nowrap sm:w-auto">
|
||||||
<IconPencil className="mr-2 size-4" />
|
<IconPencil className="mr-2 size-4" />
|
||||||
Salvar contatos
|
Salvar contatos
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -862,18 +881,19 @@ function LocationsEditor({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={submit} className="space-y-5">
|
<form onSubmit={submit} className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<p className="text-sm font-semibold text-foreground">Localizações</p>
|
<p className="text-base font-semibold text-foreground">Localizações</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Define unidades críticas, data centers e filiais para atendimento dedicado.
|
Defina unidades críticas, data centers e filiais para atendimento dedicado.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="self-start sm:self-auto"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
fieldArray.append({
|
fieldArray.append({
|
||||||
id: createId("location"),
|
id: createId("location"),
|
||||||
|
|
@ -886,18 +906,18 @@ function LocationsEditor({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconPlus className="mr-1 size-3.5" />
|
<IconPlus className="mr-2 size-4" />
|
||||||
Nova unidade
|
Nova unidade
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{fieldArray.fields.map((field, index) => {
|
{fieldArray.fields.map((field, index) => {
|
||||||
const errors = form.formState.errors.locations?.[index]
|
const errors = form.formState.errors.locations?.[index]
|
||||||
return (
|
return (
|
||||||
<Card key={field.id}>
|
<Card key={field.id} className="border border-border/60 shadow-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||||
<CardTitle className="text-base font-semibold">Unidade #{index + 1}</CardTitle>
|
<CardTitle className="text-lg font-semibold">Unidade #{index + 1}</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -908,14 +928,14 @@ function LocationsEditor({
|
||||||
<IconTrash className="size-4" />
|
<IconTrash className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Nome</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nome</Label>
|
||||||
<Input {...form.register(`locations.${index}.name` as const)} />
|
<Input {...form.register(`locations.${index}.name` as const)} />
|
||||||
<FieldError message={errors?.name?.message} />
|
<FieldError message={errors?.name?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Tipo</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Tipo</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name={`locations.${index}.type` as const}
|
name={`locations.${index}.type` as const}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
@ -935,18 +955,25 @@ function LocationsEditor({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Contato responsável</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Contato responsável
|
||||||
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name={`locations.${index}.responsibleContactId` as const}
|
name={`locations.${index}.responsibleContactId` as const}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value ?? ""} onValueChange={(value) => field.onChange(value || null)}>
|
<Select
|
||||||
|
value={field.value ?? NO_CONTACT_VALUE}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value === NO_CONTACT_VALUE ? null : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Selecione" />
|
<SelectValue placeholder="Selecione" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">Nenhum</SelectItem>
|
<SelectItem value={NO_CONTACT_VALUE}>Nenhum</SelectItem>
|
||||||
{contacts.map((contact) => (
|
{contacts.map((contact) => (
|
||||||
<SelectItem key={contact.id} value={contact.id}>
|
<SelectItem key={contact.id} value={contact.id}>
|
||||||
{contact.fullName}
|
{contact.fullName}
|
||||||
|
|
@ -957,8 +984,10 @@ function LocationsEditor({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="md:col-span-2 space-y-2">
|
||||||
<Label>Notas</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Notas
|
||||||
|
</Label>
|
||||||
<Textarea {...form.register(`locations.${index}.notes` as const)} />
|
<Textarea {...form.register(`locations.${index}.notes` as const)} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -967,11 +996,11 @@ function LocationsEditor({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">
|
<Button type="submit" className="whitespace-nowrap sm:w-auto">
|
||||||
<IconPencil className="mr-2 size-4" />
|
<IconPencil className="mr-2 size-4" />
|
||||||
Salvar localizações
|
Salvar localizações
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1019,11 +1048,11 @@ function ContractsEditor({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={submit} className="space-y-5">
|
<form onSubmit={submit} className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<p className="text-sm font-semibold text-foreground">Contratos</p>
|
<p className="text-base font-semibold text-foreground">Contratos</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Registre vigência, escopo e condições para este cliente.
|
Registre vigência, escopo e condições para este cliente.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1031,6 +1060,7 @@ function ContractsEditor({
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="self-start sm:self-auto"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
fieldArray.append({
|
fieldArray.append({
|
||||||
id: createId("contract"),
|
id: createId("contract"),
|
||||||
|
|
@ -1047,18 +1077,18 @@ function ContractsEditor({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconPlus className="mr-1 size-3.5" />
|
<IconPlus className="mr-2 size-4" />
|
||||||
Novo contrato
|
Novo contrato
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{fieldArray.fields.map((field, index) => {
|
{fieldArray.fields.map((field, index) => {
|
||||||
const errors = form.formState.errors.contracts?.[index]
|
const errors = form.formState.errors.contracts?.[index]
|
||||||
return (
|
return (
|
||||||
<Card key={field.id}>
|
<Card key={field.id} className="border border-border/60 shadow-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||||
<CardTitle className="text-base font-semibold">Contrato #{index + 1}</CardTitle>
|
<CardTitle className="text-lg font-semibold">Contrato #{index + 1}</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -1069,9 +1099,11 @@ function ContractsEditor({
|
||||||
<IconTrash className="size-4" />
|
<IconTrash className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Tipo</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Tipo
|
||||||
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name={`contracts.${index}.contractType` as const}
|
name={`contracts.${index}.contractType` as const}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
@ -1091,34 +1123,46 @@ function ContractsEditor({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>SKU/plano</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
SKU/plano
|
||||||
|
</Label>
|
||||||
<Input {...form.register(`contracts.${index}.planSku` as const)} />
|
<Input {...form.register(`contracts.${index}.planSku` as const)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Início</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Início
|
||||||
|
</Label>
|
||||||
<Input type="date" {...form.register(`contracts.${index}.startDate` as const)} />
|
<Input type="date" {...form.register(`contracts.${index}.startDate` as const)} />
|
||||||
<FieldError message={errors?.startDate?.message as string | undefined} />
|
<FieldError message={errors?.startDate?.message as string | undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Fim</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Fim
|
||||||
|
</Label>
|
||||||
<Input type="date" {...form.register(`contracts.${index}.endDate` as const)} />
|
<Input type="date" {...form.register(`contracts.${index}.endDate` as const)} />
|
||||||
<FieldError message={errors?.endDate?.message as string | undefined} />
|
<FieldError message={errors?.endDate?.message as string | undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Renovação</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Renovação
|
||||||
|
</Label>
|
||||||
<Input type="date" {...form.register(`contracts.${index}.renewalDate` as const)} />
|
<Input type="date" {...form.register(`contracts.${index}.renewalDate` as const)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>Valor (R$)</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Valor (R$)
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
{...form.register(`contracts.${index}.price` as const, { valueAsNumber: true })}
|
{...form.register(`contracts.${index}.price` as const, { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2 space-y-2">
|
||||||
<Label>Escopo</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Escopo
|
||||||
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name={`contracts.${index}.scope` as const}
|
name={`contracts.${index}.scope` as const}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
@ -1138,8 +1182,10 @@ function ContractsEditor({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2 space-y-2">
|
||||||
<Label>Observações</Label>
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Observações
|
||||||
|
</Label>
|
||||||
<Textarea {...form.register(`contracts.${index}.notes` as const)} />
|
<Textarea {...form.register(`contracts.${index}.notes` as const)} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -1148,11 +1194,11 @@ function ContractsEditor({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">
|
<Button type="submit" className="whitespace-nowrap sm:w-auto">
|
||||||
<IconPencil className="mr-2 size-4" />
|
<IconPencil className="mr-2 size-4" />
|
||||||
Salvar contratos
|
Salvar contratos
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -106,8 +106,8 @@ const navigation: NavigationGroup[] = [
|
||||||
url: "/admin/companies",
|
url: "/admin/companies",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
requiredRole: "admin",
|
requiredRole: "admin",
|
||||||
children: [{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" }],
|
|
||||||
},
|
},
|
||||||
|
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
|
||||||
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
|
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
|
||||||
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
|
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const isManager = normalizedRole === "manager"
|
const isManager = normalizedRole === "manager"
|
||||||
const canStaffComment = hasAssignee || isManager
|
const canStaffComment = hasAssignee || isManager
|
||||||
const canComment = isRequester || (isStaff && canStaffComment)
|
const canComment = isRequester || (isStaff && canStaffComment)
|
||||||
|
const allowTicketMentions = normalizedRole === "admin" || normalizedRole === "agent"
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
||||||
const updateComment = useMutation(api.tickets.updateComment)
|
const updateComment = useMutation(api.tickets.updateComment)
|
||||||
|
|
@ -303,6 +304,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
}
|
}
|
||||||
disabled={savingCommentId === commentId}
|
disabled={savingCommentId === commentId}
|
||||||
placeholder="Edite o comentário..."
|
placeholder="Edite o comentário..."
|
||||||
|
ticketMention={{ enabled: allowTicketMentions }}
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 flex items-center justify-end gap-2">
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -381,7 +383,14 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
||||||
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." className="rounded-2xl border border-slate-200" disabled={!canComment} />
|
<RichTextEditor
|
||||||
|
value={body}
|
||||||
|
onChange={setBody}
|
||||||
|
placeholder="Escreva um comentário..."
|
||||||
|
className="rounded-2xl border border-slate-200"
|
||||||
|
disabled={!canComment}
|
||||||
|
ticketMention={{ enabled: allowTicketMentions }}
|
||||||
|
/>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])}
|
onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])}
|
||||||
currentFileCount={attachmentsToSend.length}
|
currentFileCount={attachmentsToSend.length}
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const [pausing, setPausing] = useState(false)
|
const [pausing, setPausing] = useState(false)
|
||||||
const [exportingPdf, setExportingPdf] = useState(false)
|
const [exportingPdf, setExportingPdf] = useState(false)
|
||||||
const [closeOpen, setCloseOpen] = useState(false)
|
const [closeOpen, setCloseOpen] = useState(false)
|
||||||
|
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
|
||||||
|
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
|
||||||
const selectedCategoryId = categorySelection.categoryId
|
const selectedCategoryId = categorySelection.categoryId
|
||||||
const selectedSubcategoryId = categorySelection.subcategoryId
|
const selectedSubcategoryId = categorySelection.subcategoryId
|
||||||
const dirty = useMemo(
|
const dirty = useMemo(
|
||||||
|
|
@ -321,12 +323,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
setAssigneeSelection(currentAssigneeId)
|
setAssigneeSelection(currentAssigneeId)
|
||||||
throw new Error("assignee-not-allowed")
|
throw new Error("assignee-not-allowed")
|
||||||
}
|
}
|
||||||
|
const reasonValue = assigneeChangeReason.trim()
|
||||||
|
if (reasonValue.length < 5) {
|
||||||
|
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres.")
|
||||||
|
toast.error("Informe um motivo para registrar a troca do responsável.", { id: "assignee" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (reasonValue.length > 1000) {
|
||||||
|
setAssigneeReasonError("Use no máximo 1.000 caracteres.")
|
||||||
|
toast.error("Reduza o motivo para até 1.000 caracteres.", { id: "assignee" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAssigneeReasonError(null)
|
||||||
toast.loading("Atualizando responsável...", { id: "assignee" })
|
toast.loading("Atualizando responsável...", { id: "assignee" })
|
||||||
try {
|
try {
|
||||||
await changeAssignee({
|
await changeAssignee({
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
assigneeId: assigneeSelection as Id<"users">,
|
assigneeId: assigneeSelection as Id<"users">,
|
||||||
actorId: convexUserId as Id<"users">,
|
actorId: convexUserId as Id<"users">,
|
||||||
|
reason: reasonValue,
|
||||||
})
|
})
|
||||||
toast.success("Responsável atualizado!", { id: "assignee" })
|
toast.success("Responsável atualizado!", { id: "assignee" })
|
||||||
if (assigneeSelection) {
|
if (assigneeSelection) {
|
||||||
|
|
@ -341,6 +356,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setAssigneeChangeReason("")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast.error("Não foi possível atualizar o responsável.", { id: "assignee" })
|
toast.error("Não foi possível atualizar o responsável.", { id: "assignee" })
|
||||||
|
|
@ -387,11 +403,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
})
|
})
|
||||||
setQueueSelection(currentQueueName)
|
setQueueSelection(currentQueueName)
|
||||||
setAssigneeSelection(currentAssigneeId)
|
setAssigneeSelection(currentAssigneeId)
|
||||||
|
setAssigneeChangeReason("")
|
||||||
|
setAssigneeReasonError(null)
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editing) return
|
if (editing) return
|
||||||
|
setAssigneeChangeReason("")
|
||||||
|
setAssigneeReasonError(null)
|
||||||
setCategorySelection({
|
setCategorySelection({
|
||||||
categoryId: ticket.category?.id ?? "",
|
categoryId: ticket.category?.id ?? "",
|
||||||
subcategoryId: ticket.subcategory?.id ?? "",
|
subcategoryId: ticket.subcategory?.id ?? "",
|
||||||
|
|
@ -1097,6 +1117,28 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<span className={sectionValueClass}>{assigneeState?.name ?? "Não atribuído"}</span>
|
<span className={sectionValueClass}>{assigneeState?.name ?? "Não atribuído"}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{editing && assigneeDirty ? (
|
||||||
|
<div className="flex flex-col gap-2 sm:col-span-2 lg:col-span-3">
|
||||||
|
<span className={sectionLabelClass}>Motivo da troca</span>
|
||||||
|
<Textarea
|
||||||
|
value={assigneeChangeReason}
|
||||||
|
onChange={(event) => {
|
||||||
|
setAssigneeChangeReason(event.target.value)
|
||||||
|
if (assigneeReasonError) {
|
||||||
|
setAssigneeReasonError(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Explique brevemente por que o chamado será reatribuído..."
|
||||||
|
className="min-h-[96px]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
O motivo é registrado como comentário interno visível para administradores e agentes.
|
||||||
|
</p>
|
||||||
|
{assigneeReasonError ? (
|
||||||
|
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className={sectionLabelClass}>Criado em</span>
|
<span className={sectionLabelClass}>Criado em</span>
|
||||||
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { useEditor, EditorContent } from "@tiptap/react"
|
import { useEditor, EditorContent } from "@tiptap/react"
|
||||||
import StarterKit from "@tiptap/starter-kit"
|
import StarterKit from "@tiptap/starter-kit"
|
||||||
import Link from "@tiptap/extension-link"
|
import Link from "@tiptap/extension-link"
|
||||||
import Placeholder from "@tiptap/extension-placeholder"
|
import Placeholder from "@tiptap/extension-placeholder"
|
||||||
|
import Mention from "@tiptap/extension-mention"
|
||||||
|
import { ReactRenderer } from "@tiptap/react"
|
||||||
|
import tippy, { type Instance, type Props as TippyProps } from "tippy.js"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import sanitize from "sanitize-html"
|
import sanitize from "sanitize-html"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -33,8 +43,356 @@ type RichTextEditorProps = {
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
minHeight?: number
|
minHeight?: number
|
||||||
|
ticketMention?: {
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TicketMentionItem = {
|
||||||
|
id: string
|
||||||
|
reference: number
|
||||||
|
subject: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
requesterName: string | null
|
||||||
|
assigneeName: string | null
|
||||||
|
companyName: string | null
|
||||||
|
url: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketMentionSuggestionProps = {
|
||||||
|
command: (item: TicketMentionItem) => void
|
||||||
|
items: TicketMentionItem[]
|
||||||
|
onRegister?: (handler: ((event: KeyboardEvent) => boolean) | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICKET_MENTION_CLASS = "ticket-mention"
|
||||||
|
|
||||||
|
function formatMentionSubject(subject: string) {
|
||||||
|
if (!subject) return ""
|
||||||
|
return subject.length > 60 ? `${subject.slice(0, 57)}…` : subject
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
PENDING: "Pendente",
|
||||||
|
AWAITING_ATTENDANCE: "Em andamento",
|
||||||
|
PAUSED: "Pausado",
|
||||||
|
RESOLVED: "Resolvido",
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTone: Record<string, string> = {
|
||||||
|
PENDING: "bg-amber-400",
|
||||||
|
AWAITING_ATTENDANCE: "bg-sky-500",
|
||||||
|
PAUSED: "bg-violet-500",
|
||||||
|
RESOLVED: "bg-emerald-500",
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityLabels: Record<string, string> = {
|
||||||
|
LOW: "Baixa",
|
||||||
|
MEDIUM: "Média",
|
||||||
|
HIGH: "Alta",
|
||||||
|
URGENT: "Urgente",
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionCache = new Map<string, TicketMentionItem[]>()
|
||||||
|
let mentionAbortController: AbortController | null = null
|
||||||
|
|
||||||
|
async function fetchTicketMentions(query: string): Promise<TicketMentionItem[]> {
|
||||||
|
const cacheKey = query.trim().toLowerCase()
|
||||||
|
if (mentionCache.has(cacheKey)) {
|
||||||
|
return mentionCache.get(cacheKey) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mentionAbortController?.abort()
|
||||||
|
mentionAbortController = new AbortController()
|
||||||
|
const response = await fetch(`/api/tickets/mentions?q=${encodeURIComponent(query)}`, {
|
||||||
|
signal: mentionAbortController.signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const json = (await response.json()) as { items?: TicketMentionItem[] }
|
||||||
|
const items = Array.isArray(json.items) ? json.items : []
|
||||||
|
mentionCache.set(cacheKey, items)
|
||||||
|
return items
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).name === "AbortError") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TicketMentionList({ items, command, onRegister }: TicketMentionSuggestionProps) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const item = items[index]
|
||||||
|
if (item) {
|
||||||
|
command(item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[command, items]
|
||||||
|
)
|
||||||
|
|
||||||
|
const upHandler = useCallback(() => {
|
||||||
|
setSelectedIndex((prev) => {
|
||||||
|
const nextIndex = (prev + items.length - 1) % items.length
|
||||||
|
return nextIndex
|
||||||
|
})
|
||||||
|
}, [items.length])
|
||||||
|
|
||||||
|
const downHandler = useCallback(() => {
|
||||||
|
setSelectedIndex((prev) => {
|
||||||
|
const nextIndex = (prev + 1) % items.length
|
||||||
|
return nextIndex
|
||||||
|
})
|
||||||
|
}, [items.length])
|
||||||
|
|
||||||
|
const enterHandler = useCallback(() => {
|
||||||
|
selectItem(selectedIndex)
|
||||||
|
}, [selectItem, selectedIndex])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
upHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
downHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
enterHandler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
[downHandler, enterHandler, upHandler]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onRegister?.(handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
onRegister?.(null)
|
||||||
|
}
|
||||||
|
}, [handleKeyDown, onRegister])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-[260px] p-3 text-sm text-muted-foreground">
|
||||||
|
Nenhum chamado encontrado com esse termo.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-72 min-w-[320px] space-y-1 overflow-y-auto p-2">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isActive = index === selectedIndex
|
||||||
|
const status = statusLabels[item.status] ?? item.status
|
||||||
|
const statusDot = statusTone[item.status] ?? "bg-slate-400"
|
||||||
|
const priority = priorityLabels[item.priority] ?? item.priority
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-lg border border-transparent px-3 py-2 text-left transition",
|
||||||
|
isActive ? "border-slate-200 bg-slate-100" : "hover:bg-slate-50"
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
selectItem(index)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-neutral-900">#{item.reference}</span>
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-neutral-500">{priority}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-1 text-sm text-neutral-700">{formatMentionSubject(item.subject)}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className={cn("inline-flex size-2 rounded-full", statusDot)} />
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
{item.companyName ? <span>• {item.companyName}</span> : null}
|
||||||
|
{item.assigneeName ? <span>• {item.assigneeName}</span> : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TicketMentionList.displayName = "TicketMentionList"
|
||||||
|
|
||||||
|
const TicketMentionListComponent = (props: TicketMentionSuggestionProps) => (
|
||||||
|
<TicketMentionList {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const TicketMentionExtension = Mention.extend({
|
||||||
|
name: "ticketMention",
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: { default: null },
|
||||||
|
reference: { default: null },
|
||||||
|
subject: { default: null },
|
||||||
|
status: { default: null },
|
||||||
|
priority: { default: null },
|
||||||
|
url: { default: null },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `a[data-ticket-mention="true"]`,
|
||||||
|
getAttrs: (dom: HTMLElement | string) => {
|
||||||
|
if (dom instanceof HTMLElement) {
|
||||||
|
return {
|
||||||
|
id: dom.dataset.ticketId ?? null,
|
||||||
|
reference: dom.dataset.ticketReference ?? null,
|
||||||
|
status: dom.dataset.ticketStatus ?? null,
|
||||||
|
priority: dom.dataset.ticketPriority ?? null,
|
||||||
|
subject: dom.getAttribute("data-ticket-subject") ?? dom.textContent ?? null,
|
||||||
|
url: dom.getAttribute("href") ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||||
|
const referenceValue =
|
||||||
|
HTMLAttributes.reference ?? HTMLAttributes["data-ticket-reference"] ?? ""
|
||||||
|
const reference = String(referenceValue ?? "")
|
||||||
|
const subjectValue =
|
||||||
|
HTMLAttributes.subject ?? HTMLAttributes["data-ticket-subject"] ?? ""
|
||||||
|
const subject = String(subjectValue ?? "")
|
||||||
|
const status = String(HTMLAttributes.status ?? HTMLAttributes["data-ticket-status"] ?? "PENDING").toUpperCase()
|
||||||
|
const priority = String(HTMLAttributes.priority ?? HTMLAttributes["data-ticket-priority"] ?? "MEDIUM").toUpperCase()
|
||||||
|
const href = String(HTMLAttributes.url ?? HTMLAttributes.href ?? "#")
|
||||||
|
return [
|
||||||
|
"a",
|
||||||
|
{
|
||||||
|
...HTMLAttributes,
|
||||||
|
href,
|
||||||
|
"data-ticket-mention": "true",
|
||||||
|
"data-ticket-id": HTMLAttributes.id ?? HTMLAttributes["data-ticket-id"] ?? "",
|
||||||
|
"data-ticket-reference": reference ?? "",
|
||||||
|
"data-ticket-status": status,
|
||||||
|
"data-ticket-priority": priority,
|
||||||
|
"data-ticket-subject": subject ?? "",
|
||||||
|
class: TICKET_MENTION_CLASS,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
"span",
|
||||||
|
{ class: "ticket-mention-dot" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"span",
|
||||||
|
{ class: "ticket-mention-ref" },
|
||||||
|
`#${reference ?? ""}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"span",
|
||||||
|
{ class: "ticket-mention-sep" },
|
||||||
|
"•",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"span",
|
||||||
|
{ class: "ticket-mention-subject" },
|
||||||
|
formatMentionSubject(subject ?? ""),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
renderLabel({ node }: { node: { attrs: Record<string, unknown> } }) {
|
||||||
|
const subjectAttr = node.attrs.subject ?? node.attrs["data-ticket-subject"] ?? ""
|
||||||
|
const displayedSubject = typeof subjectAttr === "string" ? subjectAttr : String(subjectAttr ?? "")
|
||||||
|
const refAttr = node.attrs.reference ?? node.attrs.id ?? node.attrs["data-ticket-reference"] ?? ""
|
||||||
|
const reference = typeof refAttr === "string" ? refAttr : String(refAttr ?? "")
|
||||||
|
const subjectPart = displayedSubject ? ` • ${formatMentionSubject(displayedSubject)}` : ""
|
||||||
|
return `#${reference}${subjectPart}`
|
||||||
|
},
|
||||||
|
}).configure({
|
||||||
|
suggestion: {
|
||||||
|
char: "@",
|
||||||
|
startOfLine: false,
|
||||||
|
allowSpaces: false,
|
||||||
|
render: () => {
|
||||||
|
let component: ReactRenderer | null = null
|
||||||
|
let popup: Instance<TippyProps> | null = null
|
||||||
|
let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null
|
||||||
|
const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => {
|
||||||
|
keydownHandler = handler
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
onStart: (props) => {
|
||||||
|
const listProps: TicketMentionSuggestionProps = {
|
||||||
|
command: props.command,
|
||||||
|
items: props.items,
|
||||||
|
onRegister: registerHandler,
|
||||||
|
}
|
||||||
|
component = new ReactRenderer(TicketMentionListComponent, {
|
||||||
|
props: listProps,
|
||||||
|
editor: props.editor,
|
||||||
|
})
|
||||||
|
if (!props.clientRect) return
|
||||||
|
popup = tippy(document.body, {
|
||||||
|
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onUpdate(props) {
|
||||||
|
component?.updateProps({
|
||||||
|
command: props.command,
|
||||||
|
items: props.items,
|
||||||
|
onRegister: registerHandler,
|
||||||
|
})
|
||||||
|
if (!props.clientRect) return
|
||||||
|
popup?.setProps({
|
||||||
|
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onKeyDown(props) {
|
||||||
|
if (props.event.key === "Escape") {
|
||||||
|
popup?.hide()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (keydownHandler && keydownHandler(props.event)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onExit() {
|
||||||
|
popup?.destroy()
|
||||||
|
component?.destroy()
|
||||||
|
keydownHandler = null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
items: async ({ query }) => {
|
||||||
|
return fetchTicketMentions(query)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export function RichTextEditor({
|
export function RichTextEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -42,9 +400,10 @@ export function RichTextEditor({
|
||||||
placeholder = "Escreva aqui...",
|
placeholder = "Escreva aqui...",
|
||||||
disabled,
|
disabled,
|
||||||
minHeight = 120,
|
minHeight = 120,
|
||||||
|
ticketMention,
|
||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
const editor = useEditor({
|
const extensions = useMemo(() => {
|
||||||
extensions: [
|
const baseExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
bulletList: { keepMarks: true },
|
bulletList: { keepMarks: true },
|
||||||
orderedList: { keepMarks: true },
|
orderedList: { keepMarks: true },
|
||||||
|
|
@ -56,6 +415,13 @@ export function RichTextEditor({
|
||||||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({ placeholder }),
|
Placeholder.configure({ placeholder }),
|
||||||
|
]
|
||||||
|
return ticketMention?.enabled ? [...baseExtensions, TicketMentionExtension] : baseExtensions
|
||||||
|
}, [placeholder, ticketMention?.enabled])
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
...extensions,
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
|
|
@ -300,15 +666,48 @@ export function sanitizeEditorHtml(html: string): string {
|
||||||
"p","br","a","strong","em","u","s","blockquote","ul","ol","li","code","pre","span","h1","h2","h3"
|
"p","br","a","strong","em","u","s","blockquote","ul","ol","li","code","pre","span","h1","h2","h3"
|
||||||
],
|
],
|
||||||
allowedAttributes: {
|
allowedAttributes: {
|
||||||
a: ["href","name","target","rel"],
|
a: [
|
||||||
span: ["style"],
|
"href",
|
||||||
|
"name",
|
||||||
|
"target",
|
||||||
|
"rel",
|
||||||
|
"class",
|
||||||
|
"data-ticket-mention",
|
||||||
|
"data-ticket-id",
|
||||||
|
"data-ticket-reference",
|
||||||
|
"data-ticket-status",
|
||||||
|
"data-ticket-priority",
|
||||||
|
"data-ticket-subject",
|
||||||
|
"title",
|
||||||
|
],
|
||||||
|
span: [
|
||||||
|
"style",
|
||||||
|
"class",
|
||||||
|
"data-ticket-mention",
|
||||||
|
"data-ticket-id",
|
||||||
|
"data-ticket-reference",
|
||||||
|
"data-ticket-status",
|
||||||
|
"data-ticket-priority",
|
||||||
|
"data-ticket-subject",
|
||||||
|
],
|
||||||
code: ["class"],
|
code: ["class"],
|
||||||
pre: ["class"],
|
pre: ["class"],
|
||||||
},
|
},
|
||||||
allowedSchemes: ["http","https","mailto"],
|
allowedSchemes: ["http","https","mailto"],
|
||||||
// prevent target=_self phishing
|
// prevent target=_self phishing
|
||||||
transformTags: {
|
transformTags: {
|
||||||
a: sanitize.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
|
a: (tagName, attribs) => {
|
||||||
|
const isMention = attribs["data-ticket-mention"] === "true"
|
||||||
|
const nextAttribs = {
|
||||||
|
...attribs,
|
||||||
|
rel: attribs.rel ?? "noopener noreferrer",
|
||||||
|
target: isMention ? "_self" : "_blank",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
attribs: nextAttribs,
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// disallow inline event handlers
|
// disallow inline event handlers
|
||||||
allowVulnerableTags: false,
|
allowVulnerableTags: false,
|
||||||
|
|
|
||||||
29
tests/change-assignee-comment.test.ts
Normal file
29
tests/change-assignee-comment.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { buildAssigneeChangeComment } from "../convex/tickets"
|
||||||
|
|
||||||
|
describe("buildAssigneeChangeComment", () => {
|
||||||
|
it("inclui nomes antigos e novos e quebra o motivo em parágrafos", () => {
|
||||||
|
const html = buildAssigneeChangeComment("Transferir para o time B\nCliente solicitou gestor.", {
|
||||||
|
previousName: "Ana",
|
||||||
|
nextName: "Bruno",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(html).toContain("Ana")
|
||||||
|
expect(html).toContain("Bruno")
|
||||||
|
expect(html).toContain("<p>Transferir para o time B</p>")
|
||||||
|
expect(html).toContain("<p>Cliente solicitou gestor.</p>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("escapa caracteres perigosos", () => {
|
||||||
|
const html = buildAssigneeChangeComment("<script>alert(1)</script>", {
|
||||||
|
previousName: "<Ana>",
|
||||||
|
nextName: "Bruno & Co",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(html).toContain("<Ana>")
|
||||||
|
expect(html).toContain("Bruno & Co")
|
||||||
|
expect(html).not.toContain("<script>")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue