feat: enhance tickets portal and admin flows

This commit is contained in:
Esdras Renan 2025-10-07 02:26:09 -03:00
parent 9cdd8763b4
commit c15f0a5b09
67 changed files with 1101 additions and 338 deletions

View file

@ -1,7 +1,7 @@
import { AdminUsersManager } from "@/components/admin/admin-users-manager"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz"
import { ROLE_OPTIONS, normalizeRole, type RoleOption } from "@/lib/authz"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { prisma } from "@/lib/prisma"
import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
@ -27,7 +27,7 @@ async function loadUsers() {
id: user.id,
email: user.email,
name: user.name ?? "",
role: normalizeRole(user.role) ?? "agent",
role: (normalizeRole(user.role) ?? "agent") as RoleOption,
tenantId: user.tenantId ?? DEFAULT_TENANT_ID,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt?.toISOString() ?? null,

View file

@ -1,8 +1,8 @@
import { NextResponse } from "next/server"
import { Prisma } from "@prisma/client"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
import { api } from "@/convex/_generated/api"
import { assertAdminSession } from "@/lib/auth-server"
import { env } from "@/lib/env"
@ -36,7 +36,8 @@ async function syncInvite(invite: NormalizedInvite) {
})
}
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
@ -46,7 +47,7 @@ export async function PATCH(request: Request, { params }: { params: { id: string
const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null
const invite = await prisma.authInvite.findUnique({
where: { id: params.id },
where: { id },
include: { events: { orderBy: { createdAt: "asc" } } },
})
@ -81,7 +82,7 @@ export async function PATCH(request: Request, { params }: { params: { id: string
data: {
inviteId: invite.id,
type: "revoked",
payload: reason ? { reason } : null,
payload: reason ? { reason } : Prisma.JsonNull,
actorId: session.user.id ?? null,
},
})

View file

@ -1,9 +1,9 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { Prisma } from "@prisma/client"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
import { api } from "@/convex/_generated/api"
import { assertAdminSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -14,6 +14,13 @@ import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type Norma
const DEFAULT_EXPIRATION_DAYS = 7
function toJsonPayload(payload: unknown): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
if (payload === null || payload === undefined) {
return Prisma.JsonNull
}
return payload as Prisma.InputJsonValue
}
function normalizeRole(input: string | null | undefined): RoleOption {
const role = (input ?? "agent").toLowerCase() as RoleOption
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
@ -52,7 +59,7 @@ async function appendEvent(inviteId: string, type: string, actorId: string | nul
data: {
inviteId,
type,
payload,
payload: toJsonPayload(payload),
actorId,
},
})

View file

@ -4,7 +4,6 @@ import { randomBytes } from "crypto"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex generated API lacks type declarations in Next API routes
import { api } from "@/convex/_generated/api"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"

View file

@ -1,9 +1,9 @@
import { NextResponse } from "next/server"
import { Prisma } from "@prisma/client"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex generated API lacks types in Next routes
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { env } from "@/lib/env"
@ -47,9 +47,10 @@ async function syncInvite(invite: NormalizedInvite) {
})
}
export async function GET(_request: Request, { params }: { params: { token: string } }) {
export async function GET(_request: Request, context: { params: Promise<{ token: string }> }) {
const { token } = await context.params
const invite = await prisma.authInvite.findUnique({
where: { token: params.token },
where: { token },
include: { events: { orderBy: { createdAt: "asc" } } },
})
@ -66,7 +67,7 @@ export async function GET(_request: Request, { params }: { params: { token: stri
data: {
inviteId: invite.id,
type: status,
payload: null,
payload: Prisma.JsonNull,
actorId: null,
},
})
@ -80,7 +81,8 @@ export async function GET(_request: Request, { params }: { params: { token: stri
return NextResponse.json({ invite: normalized })
}
export async function POST(request: Request, { params }: { params: { token: string } }) {
export async function POST(request: Request, context: { params: Promise<{ token: string }> }) {
const { token } = await context.params
const payload = (await request.json().catch(() => null)) as Partial<AcceptInvitePayload> | null
if (!payload || typeof payload.password !== "string") {
return NextResponse.json({ error: "Senha inválida" }, { status: 400 })
@ -91,7 +93,7 @@ export async function POST(request: Request, { params }: { params: { token: stri
}
const invite = await prisma.authInvite.findUnique({
where: { token: params.token },
where: { token },
include: { events: { orderBy: { createdAt: "asc" } } },
})
@ -108,7 +110,7 @@ export async function POST(request: Request, { params }: { params: { token: stri
data: {
inviteId: invite.id,
type: "expired",
payload: null,
payload: Prisma.JsonNull,
actorId: null,
},
})

View file

@ -0,0 +1,261 @@
import { NextResponse } from "next/server"
import PDFDocument from "pdfkit"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertStaffSession } from "@/lib/auth-server"
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
const statusLabel: Record<string, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const timelineLabel: Record<string, string> = {
CREATED: "Chamado criado",
STATUS_CHANGED: "Status atualizado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Novo comentário",
COMMENT_EDITED: "Comentário editado",
ATTACHMENT_REMOVED: "Anexo removido",
QUEUE_CHANGED: "Fila alterada",
PRIORITY_CHANGED: "Prioridade alterada",
WORK_STARTED: "Atendimento iniciado",
WORK_PAUSED: "Atendimento pausado",
CATEGORY_CHANGED: "Categoria alterada",
}
function formatDateTime(date: Date | null | undefined) {
if (!date) return "—"
return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR })
}
function htmlToPlainText(html?: string | null) {
if (!html) return ""
const withBreaks = html
.replace(/<\s*br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
const stripped = withBreaks.replace(/<[^>]+>/g, "")
return decodeHtmlEntities(stripped).replace(/\u00A0/g, " ").trim()
}
function decodeHtmlEntities(input: string) {
return input
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
}
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
const { id: ticketId } = await context.params
const session = await assertStaffSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
}
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
let viewerId: string | null = null
try {
const ensuredUser = await client.mutation(api.users.ensureUser, {
tenantId,
name: session.user.name ?? session.user.email,
email: session.user.email,
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role.toUpperCase(),
})
viewerId = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for PDF export", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
if (!viewerId) {
return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 })
}
let ticketRaw: unknown
try {
ticketRaw = await client.query(api.tickets.getById, {
tenantId,
id: ticketId as unknown as Id<"tickets">,
viewerId: viewerId as unknown as Id<"users">,
})
} catch (error) {
console.error("Failed to load ticket from Convex for PDF export", error, {
tenantId,
ticketId,
viewerId,
})
return NextResponse.json({ error: "Falha ao carregar ticket no Convex" }, { status: 500 })
}
if (!ticketRaw) {
return NextResponse.json({ error: "Ticket não encontrado" }, { status: 404 })
}
const ticket = mapTicketWithDetailsFromServer(ticketRaw)
const doc = new PDFDocument({ size: "A4", margin: 48 })
const chunks: Buffer[] = []
doc.on("data", (chunk) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
})
const pdfBufferPromise = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)))
doc.on("error", reject)
})
doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference}${ticket.subject}`)
doc.moveDown(0.5)
doc
.font("Helvetica")
.fontSize(11)
.text(`Status: ${statusLabel[ticket.status] ?? ticket.status}`)
.moveDown(0.15)
.text(`Prioridade: ${ticket.priority}`)
.moveDown(0.15)
.text(`Canal: ${ticket.channel}`)
.moveDown(0.15)
.text(`Fila: ${ticket.queue ?? "—"}`)
doc.moveDown(0.75)
doc
.font("Helvetica-Bold")
.fontSize(12)
.text("Solicitante")
doc
.font("Helvetica")
.fontSize(11)
.text(`${ticket.requester.name} (${ticket.requester.email})`)
doc.moveDown(0.5)
doc.font("Helvetica-Bold").fontSize(12).text("Responsável")
doc
.font("Helvetica")
.fontSize(11)
.text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído")
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Datas")
doc
.font("Helvetica")
.fontSize(11)
.text(`Criado em: ${formatDateTime(ticket.createdAt)}`)
.moveDown(0.15)
.text(`Atualizado em: ${formatDateTime(ticket.updatedAt)}`)
.moveDown(0.15)
.text(`Resolvido em: ${formatDateTime(ticket.resolvedAt ?? null)}`)
if (ticket.summary) {
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Resumo")
doc
.font("Helvetica")
.fontSize(11)
.text(ticket.summary, { align: "justify" })
}
if (ticket.description) {
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Descrição")
doc
.font("Helvetica")
.fontSize(11)
.text(htmlToPlainText(ticket.description), { align: "justify" })
}
if (ticket.comments.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Comentários")
doc.moveDown(0.5)
const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
commentsSorted.forEach((comment, index) => {
const visibility =
comment.visibility === "PUBLIC" ? "Público" : "Interno"
doc
.font("Helvetica-Bold")
.fontSize(11)
.text(
`${comment.author.name}${visibility}${formatDateTime(comment.createdAt)}`
)
const body = htmlToPlainText(comment.body)
if (body) {
doc
.font("Helvetica")
.fontSize(11)
.text(body, { align: "justify" })
}
if (comment.attachments.length > 0) {
doc.moveDown(0.25)
doc.font("Helvetica").fontSize(10).text("Anexos:")
comment.attachments.forEach((attachment) => {
doc
.font("Helvetica")
.fontSize(10)
.text(`${attachment.name ?? attachment.id}`, { indent: 12 })
})
}
if (index < commentsSorted.length - 1) {
doc.moveDown(0.75)
doc
.strokeColor("#E2E8F0")
.moveTo(doc.x, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc.moveDown(0.75)
}
})
}
if (ticket.timeline.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo")
doc.moveDown(0.5)
const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
timelineSorted.forEach((event) => {
const label = timelineLabel[event.type] ?? event.type
doc
.font("Helvetica-Bold")
.fontSize(11)
.text(`${label}${formatDateTime(event.createdAt)}`)
if (event.payload) {
const payloadText = JSON.stringify(event.payload, null, 2)
doc
.font("Helvetica")
.fontSize(10)
.text(payloadText, { indent: 12 })
}
doc.moveDown(0.5)
})
}
doc.end()
const pdfBuffer = await pdfBufferPromise
const pdfBytes = new Uint8Array(pdfBuffer)
return new NextResponse(pdfBytes, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`,
"Cache-Control": "no-store",
},
})
}

View file

@ -1,8 +1,8 @@
import { AppShell } from "@/components/app-shell"
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
import { SectionCards } from "@/components/section-cards"
import { SiteHeader } from "@/components/site-header"
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
export default function Dashboard() {
return (

View file

@ -2,7 +2,6 @@
import { useState } from "react";
import { useMutation } from "convex/react";
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api";
export default function SeedPage() {

View file

@ -0,0 +1,73 @@
"use client"
import { useEffect, useState } from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import dynamic from "next/dynamic"
import { GalleryVerticalEnd } from "lucide-react"
import { LoginForm } from "@/components/login-form"
import { useSession } from "@/lib/auth-client"
const ShaderBackground = dynamic(
() => import("@/components/background-paper-shaders-wrapper"),
{ ssr: false }
)
export function LoginPageClient() {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const callbackUrl = searchParams?.get("callbackUrl") ?? undefined
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
if (isPending) return
if (!session?.user) return
const destination = callbackUrl ?? "/dashboard"
router.replace(destination)
}, [callbackUrl, isPending, router, session?.user])
useEffect(() => {
setIsHydrated(true)
}, [])
const shouldDisable = !isHydrated || isPending
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-6 p-6 md:p-10">
<div className="flex flex-col items-center gap-1.5 text-center">
<Link href="/" className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" />
</div>
Sistema de chamados
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<LoginForm callbackUrl={callbackUrl} disabled={shouldDisable} />
</div>
</div>
<div className="flex justify-center">
<Image
src="/rever-8.png"
alt="Logotipo Rever Tecnologia"
width={110}
height={110}
className="h-[3.45rem] w-auto"
priority
/>
</div>
<footer className="flex justify-center text-sm text-neutral-500">
Desenvolvido por Esdras Renan
</footer>
</div>
<div className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" />
</div>
</div>
)
}

View file

@ -1,74 +1,11 @@
"use client"
import { Suspense } from "react"
import { useEffect, useState } from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { GalleryVerticalEnd } from "lucide-react"
import { LoginForm } from "@/components/login-form"
import { useSession } from "@/lib/auth-client"
import dynamic from "next/dynamic"
const ShaderBackground = dynamic(
() => import("@/components/background-paper-shaders-wrapper"),
{ ssr: false }
)
import { LoginPageClient } from "./login-page-client"
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const callbackUrl = searchParams?.get("callbackUrl") ?? undefined
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
if (isPending) return
if (!session?.user) return
const destination = callbackUrl ?? "/dashboard"
router.replace(destination)
}, [callbackUrl, isPending, router, session?.user])
useEffect(() => {
setIsHydrated(true)
}, [])
const shouldDisable = !isHydrated || isPending
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-6 p-6 md:p-10">
<div className="flex flex-col items-center gap-1.5 text-center">
<Link href="/" className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" />
</div>
Sistema de chamados
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<LoginForm callbackUrl={callbackUrl} disabled={shouldDisable} />
</div>
</div>
<div className="flex justify-center">
<Image
src="/rever-8.png"
alt="Logotipo Rever Tecnologia"
width={110}
height={110}
className="h-[3.45rem] w-auto"
priority
/>
</div>
<footer className="flex justify-center text-sm text-neutral-500">
Desenvolvido por Esdras Renan
</footer>
</div>
<div className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" />
</div>
</div>
<Suspense fallback={<div className="flex min-h-svh items-center justify-center">Carregando</div>}>
<LoginPageClient />
</Suspense>
)
}

View file

@ -8,7 +8,6 @@ import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"

View file

@ -3,7 +3,6 @@
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// @ts-expect-error Convex runtime API lacks generated types
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"

View file

@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"

View file

@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconInbox, IconHierarchy2, IconLink, IconPlus } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"

View file

@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"

View file

@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconUsersGroup, IconCalendarClock, IconSettings, IconUserPlus } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"

View file

@ -1,4 +1,4 @@
import type { ReactNode } from "react"
import { Suspense, type ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth/auth-guard"
@ -14,7 +14,9 @@ export function AppShell({ header, children }: AppShellProps) {
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<AuthGuard />
<Suspense fallback={null}>
<AuthGuard />
</Suspense>
{header}
<main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6">
{children}

View file

@ -2,18 +2,18 @@
import { MeshGradient } from "@paper-design/shaders-react"
export default function BackgroundPaperShadersWrapper() {
import { cn } from "@/lib/utils"
export default function BackgroundPaperShadersWrapper({ className }: { className?: string }) {
const speed = 1.0
return (
<div className="w-full h-full bg-black relative overflow-hidden">
<div className={cn("relative h-full w-full overflow-hidden bg-black", className)}>
<MeshGradient
className="w-full h-full absolute inset-0"
className="absolute inset-0 h-full w-full"
colors={["#000000", "#1a1a1a", "#333333", "#ffffff"]}
speed={speed * 0.5}
wireframe="true"
/>
</div>
)
}

View file

@ -103,7 +103,16 @@ export function EnergyRing({
useFrame((state) => {
if (mesh.current) {
mesh.current.rotation.z = state.clock.elapsedTime
mesh.current.material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
const material = mesh.current.material
if (Array.isArray(material)) {
material.forEach((mat) => {
if ("opacity" in mat) {
mat.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
}
})
} else if (material && "opacity" in material) {
material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
}
}
})

View file

@ -4,7 +4,6 @@ import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
@ -39,17 +38,22 @@ import {
export const description = "Distribuição semanal de tickets por canal"
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
export function ChartAreaInteractive() {
const [mounted, setMounted] = React.useState(false)
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("7d")
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
React.useEffect(() => {
setMounted(true)
}, [])
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
const report = useQuery(
api.reports.ticketsByChannel,
@ -72,7 +76,7 @@ export function ChartAreaInteractive() {
)
const chartConfig = React.useMemo(() => {
const entries = channels.map((channel, index) => [
const entries = channels.map((channel: string, index: number) => [
channel,
{
label: channel
@ -87,7 +91,7 @@ export function ChartAreaInteractive() {
const chartData = React.useMemo(() => {
if (!report?.points) return []
return report.points.map((point) => {
return report.points.map((point: { date: string; values: Record<string, number> }) => {
const entry: Record<string, number | string> = { date: point.date }
for (const channel of channels) {
entry[channel] = point.values[channel] ?? 0
@ -95,6 +99,14 @@ export function ChartAreaInteractive() {
return entry
})
}, [channels, report])
if (!mounted) {
return (
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
Carregando gráfico...
</div>
)
}
return (
<Card className="@container/card">
@ -156,7 +168,7 @@ export function ChartAreaInteractive() {
>
<AreaChart data={chartData}>
<defs>
{channels.map((channel) => (
{channels.map((channel: string) => (
<linearGradient key={channel} id={`fill-${channel}`} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
@ -203,7 +215,7 @@ export function ChartAreaInteractive() {
{channels
.slice()
.reverse()
.map((channel) => (
.map((channel: string) => (
<Area
key={channel}
dataKey={channel}
@ -212,7 +224,11 @@ export function ChartAreaInteractive() {
stroke={chartConfig[channel]?.color ?? "var(--chart-1)"}
strokeWidth={2}
stackId="a"
name={chartConfig[channel]?.label ?? channel}
name={
typeof chartConfig[channel]?.label === "string"
? (chartConfig[channel]?.label as string)
: channel
}
/>
))}
</AreaChart>
@ -221,4 +237,6 @@ export function ChartAreaInteractive() {
</CardContent>
</Card>
)
}
}
export default ChartAreaInteractive

View file

@ -12,19 +12,17 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { cn } from "@/lib/utils"
const statusLabel: Record<Ticket["status"], string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const statusTone: Record<Ticket["status"], string> = {
NEW: "bg-slate-200 text-slate-800",
OPEN: "bg-sky-100 text-sky-700",
PENDING: "bg-amber-100 text-amber-700",
ON_HOLD: "bg-violet-100 text-violet-700",
PENDING: "bg-slate-200 text-slate-800",
AWAITING_ATTENDANCE: "bg-sky-100 text-sky-700",
PAUSED: "bg-violet-100 text-violet-700",
RESOLVED: "bg-emerald-100 text-emerald-700",
CLOSED: "bg-slate-100 text-slate-600",
}

View file

@ -6,7 +6,6 @@ import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { MessageCircle } from "lucide-react"
import { toast } from "sonner"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -23,10 +22,9 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
const statusLabel: Record<TicketWithDetails["status"], string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
@ -126,7 +124,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId || !comment.trim()) return
if (!convexUserId || !comment.trim() || !ticket) return
const toastId = "portal-add-comment"
toast.loading("Enviando comentário...", { id: toastId })
try {

View file

@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useMutation } from "convex/react"
import { toast } from "sonner"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"

View file

@ -2,7 +2,6 @@
import { useMemo } from "react"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"

View file

@ -3,7 +3,6 @@
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconInbox, IconAlertTriangle, IconFilter } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
@ -20,12 +19,11 @@ const PRIORITY_LABELS: Record<string, string> = {
}
const STATUS_LABELS: Record<string, string> = {
NEW: "Novo",
OPEN: "Em andamento",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Encerrado",
PENDING: "Pendentes",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausados",
RESOLVED: "Resolvidos",
CLOSED: "Encerrados",
}
export function BacklogReport() {
@ -38,7 +36,7 @@ export function BacklogReport() {
const mostCriticalPriority = useMemo(() => {
if (!data) return null
const entries = Object.entries(data.priorityCounts)
const entries = Object.entries(data.priorityCounts) as Array<[string, number]>
if (entries.length === 0) return null
return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev))
}, [data])
@ -104,7 +102,7 @@ export function BacklogReport() {
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Object.entries(data.statusCounts).map(([status, total]) => (
{(Object.entries(data.statusCounts) as Array<[string, number]>).map(([status, total]) => (
<div key={status} className="rounded-xl border border-slate-200 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
{STATUS_LABELS[status] ?? status}
@ -125,7 +123,7 @@ export function BacklogReport() {
</CardHeader>
<CardContent>
<div className="space-y-3">
{Object.entries(data.priorityCounts).map(([priority, total]) => (
{(Object.entries(data.priorityCounts) as Array<[string, number]>).map(([priority, total]) => (
<div key={priority} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-neutral-800">
{PRIORITY_LABELS[priority] ?? priority}
@ -153,7 +151,7 @@ export function BacklogReport() {
</p>
) : (
<ul className="space-y-3">
{data.queueCounts.map((queue) => (
{data.queueCounts.map((queue: { id: string; name: string; total: number }) => (
<li key={queue.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>

View file

@ -2,7 +2,6 @@
import { useQuery } from "convex/react"
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
@ -68,7 +67,7 @@ export function CsatReport() {
{data.recent.length === 0 ? (
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
) : (
data.recent.map((item) => (
data.recent.map((item: { ticketId: string; reference: number; score: number; receivedAt: number }) => (
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
<span>#{item.reference}</span>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
@ -90,7 +89,7 @@ export function CsatReport() {
</CardHeader>
<CardContent>
<ul className="space-y-3">
{data.distribution.map((entry) => (
{data.distribution.map((entry: { score: number; total: number }) => (
<li key={entry.score} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">

View file

@ -3,7 +3,6 @@
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconAlertTriangle, IconGraph, IconClockHour4 } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
@ -29,7 +28,10 @@ export function SlaReport() {
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const queueTotal = useMemo(() => data?.queueBreakdown.reduce((acc, queue) => acc + queue.open, 0) ?? 0, [data])
const queueTotal = useMemo(
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
[data]
)
if (!data) {
return (
@ -104,7 +106,7 @@ export function SlaReport() {
</p>
) : (
<ul className="space-y-3">
{data.queueBreakdown.map((queue) => (
{data.queueBreakdown.map((queue: { id: string; name: string; open: number }) => (
<li key={queue.id} className="flex items-center justify-between gap-4 rounded-xl border border-slate-200 px-4 py-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>

View file

@ -3,7 +3,6 @@
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"

View file

@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconFileText, IconPlus, IconTrash, IconX } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"

View file

@ -3,7 +3,6 @@
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TS declarations until build
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"

View file

@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from "react"
import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"

View file

@ -5,7 +5,6 @@ import { useState } from "react"
import { useRouter } from "next/navigation"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"

View file

@ -2,7 +2,6 @@
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority } from "@/lib/schemas/ticket"

View file

@ -5,7 +5,6 @@ import Link from "next/link"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TS declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"

View file

@ -5,10 +5,9 @@ import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
PENDING: { label: "Pendente", className: "border border-slate-200 bg-slate-100 text-slate-700" },
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
}

View file

@ -2,7 +2,6 @@
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketStatus } from "@/lib/schemas/ticket"
@ -13,14 +12,20 @@ import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
NEW: { label: "Novo", badgeClass: "bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", badgeClass: "bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED", "CLOSED"];
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
PAUSED: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" },
}
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
};
const triggerClass =
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
@ -53,14 +58,14 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic
>
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
<SelectValue asChild>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
{statusStyles[status]?.label ?? status}
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => (
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option} className={itemClass}>
{statusStyles[option].label}
</SelectItem>

View file

@ -6,7 +6,6 @@ import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
import { useAction, useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"

View file

@ -1,7 +1,6 @@
"use client";
import { useQuery } from "convex/react";
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";

View file

@ -1,7 +1,6 @@
"use client"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketQueueSummary } from "@/lib/schemas/ticket"

View file

@ -1,12 +1,11 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { IconClock, IconFileDownload, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// @ts-expect-error Convex generates JS module without TS definitions
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
@ -20,6 +19,9 @@ import { StatusSelect } from "@/components/tickets/status-select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { useTicketCategories } from "@/hooks/use-ticket-categories"
import { useDefaultQueues } from "@/hooks/use-default-queues"
@ -44,6 +46,11 @@ const subtleBadgeClass =
const EMPTY_CATEGORY_VALUE = "__none__"
const EMPTY_SUBCATEGORY_VALUE = "__none__"
const PAUSE_REASONS = [
{ value: "NO_CONTACT", label: "Falta de contato" },
{ value: "WAITING_THIRD_PARTY", label: "Aguardando terceiro" },
{ value: "IN_PROCEDURE", label: "Em procedimento" },
]
function formatDuration(durationMs: number) {
if (durationMs <= 0) return "0s"
@ -104,6 +111,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
)
const [saving, setSaving] = useState(false)
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
const [pauseNote, setPauseNote] = useState("")
const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo(
@ -272,6 +284,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return () => clearInterval(interval)
}, [workSummary?.activeSession])
useEffect(() => {
if (!pauseDialogOpen) {
setPauseReason(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
setPauseNote("")
setPausing(false)
}
}, [pauseDialogOpen])
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
@ -281,6 +301,74 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
[ticket.updatedAt]
)
const handleStartWork = async () => {
if (!convexUserId) return
toast.dismiss("work")
toast.loading("Iniciando atendimento...", { id: "work" })
try {
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_started") {
toast.info("O atendimento já estava em andamento", { id: "work" })
} else {
toast.success("Atendimento iniciado", { id: "work" })
}
} catch {
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
}
}
const handlePauseConfirm = async () => {
if (!convexUserId) return
toast.dismiss("work")
toast.loading("Pausando atendimento...", { id: "work" })
setPausing(true)
try {
const result = await pauseWork({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
reason: pauseReason,
note: pauseNote.trim() ? pauseNote.trim() : undefined,
})
if (result?.status === "already_paused") {
toast.info("O atendimento já estava pausado", { id: "work" })
} else {
toast.success("Atendimento pausado", { id: "work" })
}
setPauseDialogOpen(false)
} catch {
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
} finally {
setPausing(false)
}
}
const handleExportPdf = useCallback(async () => {
try {
setExportingPdf(true)
toast.dismiss("ticket-export")
toast.loading("Gerando PDF...", { id: "ticket-export" })
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`)
if (!response.ok) {
throw new Error(`failed: ${response.status}`)
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `ticket-${ticket.reference}.pdf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
toast.success("PDF exportado com sucesso!", { id: "ticket-export" })
} catch (error) {
console.error(error)
toast.error("Não foi possível exportar o PDF.", { id: "ticket-export" })
} finally {
setExportingPdf(false)
}
}, [ticket.id, ticket.reference])
return (
<div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3">
@ -294,6 +382,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
Editar
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-800 hover:bg-slate-50"
onClick={handleExportPdf}
disabled={exportingPdf}
>
{exportingPdf ? <Spinner className="size-3 text-neutral-700" /> : <IconFileDownload className="size-4" />}
Exportar PDF
</Button>
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
@ -305,28 +403,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Button
size="sm"
className={isPlaying ? pauseButtonClass : startButtonClass}
onClick={async () => {
onClick={() => {
if (!convexUserId) return
toast.dismiss("work")
toast.loading(isPlaying ? "Pausando atendimento..." : "Iniciando atendimento...", { id: "work" })
try {
if (isPlaying) {
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_paused") {
toast.info("O atendimento já estava pausado", { id: "work" })
} else {
toast.success("Atendimento pausado", { id: "work" })
}
} else {
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_started") {
toast.info("O atendimento já estava em andamento", { id: "work" })
} else {
toast.success("Atendimento iniciado", { id: "work" })
}
}
} catch {
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
if (isPlaying) {
setPauseDialogOpen(true)
} else {
void handleStartWork()
}
}}
>
@ -539,6 +621,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
) : null}
</div>
<Dialog open={pauseDialogOpen} onOpenChange={setPauseDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrar pausa</DialogTitle>
<DialogDescription>Informe o motivo da pausa para registrar no histórico do chamado.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Motivo</span>
<Select value={pauseReason} onValueChange={setPauseReason}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{PAUSE_REASONS.map((reason) => (
<SelectItem key={reason.value} value={reason.value}>
{reason.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Observações</span>
<Textarea
value={pauseNote}
onChange={(event) => setPauseNote(event.target.value)}
rows={3}
placeholder="Adicione detalhes opcionais (visível apenas internamente)."
className="min-h-[96px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPauseDialogOpen(false)} disabled={pausing}>
Cancelar
</Button>
<Button
className={pauseButtonClass}
onClick={handlePauseConfirm}
disabled={pausing || !pauseReason}
>
{pausing ? <Spinner className="size-4 text-white" /> : "Registrar pausa"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -1,5 +1,5 @@
import { format } from "date-fns"
import type { ComponentType } from "react"
import type { ComponentType, ReactNode } from "react"
import { ptBR } from "date-fns/locale"
import {
IconClockHour4,
@ -119,9 +119,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
sessionDurationMs?: number
categoryName?: string
subcategoryName?: string
pauseReason?: string
pauseReasonLabel?: string
pauseNote?: string
}
let message: string | null = null
let message: ReactNode = null
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to)
}
@ -153,8 +156,22 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) {
message = `Anexo removido: ${payload.attachmentName}`
}
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`
if (entry.type === "WORK_PAUSED") {
const parts: string[] = []
if (payload.pauseReasonLabel || payload.pauseReason) {
parts.push(`Motivo: ${payload.pauseReasonLabel ?? payload.pauseReason}`)
}
if (typeof payload.sessionDurationMs === "number") {
parts.push(`Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`)
}
message = (
<div className="space-y-1">
<span>{parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"}</span>
{payload.pauseNote ? (
<span className="block text-xs text-neutral-500">Observação: {payload.pauseNote}</span>
) : null}
</div>
)
}
if (entry.type === "CATEGORY_CHANGED") {
if (payload.categoryName || payload.subcategoryName) {
@ -168,9 +185,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
if (!message) return null
return (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">
{message}
</div>
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">{message}</div>
)
})()}
</div>

View file

@ -6,7 +6,7 @@ import { IconFilter, IconRefresh } from "@tabler/icons-react"
import {
ticketChannelSchema,
ticketPrioritySchema,
ticketStatusSchema,
type TicketStatus,
} from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@ -24,17 +24,18 @@ import {
SelectValue,
} from "@/components/ui/select"
const statusOptions = ticketStatusSchema.options.map((status) => ({
value: status,
label: {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}[status],
}))
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
{ value: "PENDING", label: "Pendente" },
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
{ value: "PAUSED", label: "Pausado" },
{ value: "RESOLVED", label: "Resolvido" },
{ value: "CLOSED", label: "Fechado" },
]
const statusLabelMap = statusOptions.reduce<Record<TicketStatus, string>>((acc, option) => {
acc[option.value] = option.label
return acc
}, {} as Record<TicketStatus, string>)
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
value: priority,
@ -62,10 +63,11 @@ type QueueOption = string
export type TicketFiltersState = {
search: string
status: string | null
status: TicketStatus | null
priority: string | null
queue: string | null
channel: string | null
view: "active" | "completed"
}
export const defaultTicketFilters: TicketFiltersState = {
@ -74,6 +76,7 @@ export const defaultTicketFilters: TicketFiltersState = {
priority: null,
queue: null,
channel: null,
view: "active",
}
interface TicketsFiltersProps {
@ -97,10 +100,11 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
const activeFilters = useMemo(() => {
const chips: string[] = []
if (filters.status) chips.push(`Status: ${filters.status}`)
if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`)
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
return chips
}, [filters])
@ -132,6 +136,18 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
</Select>
</div>
<div className="flex items-center gap-2">
<Select
value={filters.view}
onValueChange={(value) => setPartial({ view: value as TicketFiltersState["view"] })}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Em andamento</SelectItem>
<SelectItem value="completed">Concluídos</SelectItem>
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button
@ -150,7 +166,9 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
</p>
<Select
value={filters.status ?? ALL_VALUE}
onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
onValueChange={(value) =>
setPartial({ status: value === ALL_VALUE ? null : (value as TicketStatus) })
}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />

View file

@ -49,19 +49,17 @@ const tableRowClass =
"group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
const statusLabel: Record<TicketStatus, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const statusTone: Record<TicketStatus, string> = {
NEW: "text-slate-700",
OPEN: "text-sky-700",
PENDING: "text-amber-700",
ON_HOLD: "text-violet-700",
PENDING: "text-slate-700",
AWAITING_ATTENDANCE: "text-sky-700",
PAUSED: "text-violet-700",
RESOLVED: "text-emerald-700",
CLOSED: "text-slate-600",
}

View file

@ -2,7 +2,6 @@
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -42,9 +41,23 @@ export function TicketsView() {
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
const filteredTickets = useMemo(() => {
if (!filters.queue) return tickets
return tickets.filter((t: Ticket) => t.queue === filters.queue)
}, [tickets, filters.queue])
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED", "CLOSED"])
let working = tickets
if (!filters.status) {
if (filters.view === "active") {
working = working.filter((t) => !completedStatuses.has(t.status))
} else if (filters.view === "completed") {
working = working.filter((t) => completedStatuses.has(t.status))
}
}
if (filters.queue) {
working = working.filter((t) => t.queue === filters.queue)
}
return working
}, [tickets, filters.queue, filters.status, filters.view])
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">

View file

@ -16,20 +16,17 @@ const DotOrbit = dynamic(
function ShaderVisual() {
return (
<div className="absolute inset-0">
<MeshGradient
className="absolute inset-0"
colors={["#020202", "#04131f", "#062534", "#0b3947"]}
speed={0.8}
backgroundColor="#020202"
wireframe="true"
/>
<MeshGradient className="absolute inset-0" colors={["#020202", "#04131f", "#062534", "#0b3947"]} speed={0.8} />
<div className="absolute inset-0 opacity-70">
<DotOrbit
className="h-full w-full"
dotColor="#0f172a"
orbitColor="#155e75"
colors={["#0f172a", "#155e75", "#22d3ee"]}
colorBack="#020617"
speed={1.4}
intensity={1.2}
size={0.9}
sizeRange={0.4}
spreading={1.0}
stepsPerColor={3}
/>
</div>
<div className="pointer-events-none absolute inset-0">

View file

@ -1,7 +1,6 @@
"use client";
import { useAction } from "convex/react";
// @ts-expect-error Convex generates runtime API without TS metadata
import { api } from "@/convex/_generated/api";
import { useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";

View file

@ -28,19 +28,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
style: baseStyle,
classNames: {
icon: "size-[14px] text-white [&>svg]:size-[14px] [&>svg]:text-white",
actionButton: "bg-white text-black border border-black rounded-lg",
cancelButton: "bg-transparent text-white border border-white/40 rounded-lg",
},
descriptionClassName: "text-white/80",
actionButtonClassName: "bg-white text-black border border-black rounded-lg",
cancelButtonClassName: "bg-transparent text-white border border-white/40 rounded-lg",
iconTheme: {
primary: "#ffffff",
secondary: "#000000",
},
success: { className: baseClass, style: baseStyle },
error: { className: baseClass, style: baseStyle },
info: { className: baseClass, style: baseStyle },
warning: { className: baseClass, style: baseStyle },
loading: { className: baseClass, style: baseStyle },
}}
style={
{

View file

@ -2,7 +2,6 @@
import { useEffect, useRef } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
export function useDefaultQueues(tenantId?: string | null) {

View file

@ -2,7 +2,6 @@
import { useEffect, useMemo, useRef } from "react"
import { useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex generates runtime API without TS declarations
import { api } from "@/convex/_generated/api"
import type { TicketCategory } from "@/lib/schemas/category"

View file

@ -3,9 +3,9 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react"
import { customSessionClient } from "better-auth/client/plugins"
import { createAuthClient } from "better-auth/react"
import type { AppAuth } from "@/lib/auth"
import { useMutation } from "convex/react"
// @ts-expect-error Convex generates runtime API without types until build
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { isAdmin, isCustomer, isStaff } from "@/lib/authz"
@ -26,7 +26,7 @@ export type AppSession = {
}
const authClient = createAuthClient({
plugins: [customSessionClient<AppSession>()],
plugins: [customSessionClient<AppAuth>()],
fetchOptions: {
credentials: "include",
},

View file

@ -70,3 +70,5 @@ export const auth = betterAuth({
}),
],
})
export type AppAuth = typeof auth

View file

@ -20,4 +20,5 @@ export const env = {
BETTER_AUTH_URL: parsed.data.BETTER_AUTH_URL ?? parsed.data.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
NEXT_PUBLIC_CONVEX_URL: parsed.data.NEXT_PUBLIC_CONVEX_URL,
DATABASE_URL: parsed.data.DATABASE_URL,
NEXT_PUBLIC_APP_URL: parsed.data.NEXT_PUBLIC_APP_URL,
}

View file

@ -23,6 +23,7 @@ describe("ticket mappers", () => {
expect(ui.createdAt).toBeInstanceOf(Date);
expect(ui.updatedAt).toBeInstanceOf(Date);
expect(ui.lastTimelineEntry).toBeUndefined();
expect(ui.status).toBe("AWAITING_ATTENDANCE");
});
it("converte ticket com detalhes", () => {
@ -47,6 +48,6 @@ describe("ticket mappers", () => {
});
expect(ui.timeline[0]!.createdAt).toBeInstanceOf(Date);
expect(ui.comments[0]!.createdAt).toBeInstanceOf(Date);
expect(ui.status).toBe("AWAITING_ATTENDANCE");
});
});

View file

@ -1,5 +1,24 @@
import { z } from "zod";
import { ticketSchema, ticketWithDetailsSchema } from "@/lib/schemas/ticket";
import { ticketSchema, ticketStatusSchema, ticketWithDetailsSchema } from "@/lib/schemas/ticket";
type NormalizedTicketStatus = z.infer<typeof ticketStatusSchema>;
const STATUS_MAP: Record<string, NormalizedTicketStatus> = {
NEW: "PENDING",
PENDING: "PENDING",
OPEN: "AWAITING_ATTENDANCE",
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
ON_HOLD: "PAUSED",
PAUSED: "PAUSED",
RESOLVED: "RESOLVED",
CLOSED: "CLOSED",
};
function normalizeTicketStatus(status: unknown): NormalizedTicketStatus {
if (typeof status !== "string") return "PENDING";
const normalized = STATUS_MAP[status.toUpperCase()];
return normalized ?? "PENDING";
}
// Server shapes: datas como number (epoch ms) e alguns nullables
// Relaxamos email/urls no shape do servidor para evitar que payloads parciais quebrem o app.
@ -104,6 +123,7 @@ export function mapTicketFromServer(input: unknown) {
const s = serverTicketSchema.parse(input);
const ui = {
...s,
status: normalizeTicketStatus(s.status),
category: s.category ?? undefined,
subcategory: s.subcategory ?? undefined,
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
@ -154,6 +174,7 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
const ui = {
...s,
customFields,
status: normalizeTicketStatus(s.status),
category: s.category ?? undefined,
subcategory: s.subcategory ?? undefined,
lastTimelineEntry: s.lastTimelineEntry ?? undefined,

View file

@ -60,7 +60,7 @@ const baseTickets = [
reference: 41001,
subject: "Erro 500 ao acessar portal do cliente",
summary: "Clientes relatam erro intermitente no portal web",
status: ticketStatusSchema.enum.OPEN,
status: ticketStatusSchema.enum.AWAITING_ATTENDANCE,
priority: ticketPrioritySchema.enum.URGENT,
channel: ticketChannelSchema.enum.EMAIL,
queue: "Chamados",
@ -90,7 +90,7 @@ const baseTickets = [
reference: 41002,
subject: "Integração ERP parada",
summary: "Webhook do ERP retornando timeout",
status: ticketStatusSchema.enum.PENDING,
status: ticketStatusSchema.enum.PAUSED,
priority: ticketPrioritySchema.enum.HIGH,
channel: ticketChannelSchema.enum.WHATSAPP,
queue: "Laboratório",
@ -120,7 +120,7 @@ const baseTickets = [
reference: 41003,
subject: "Solicitação de acesso VPN",
summary: "Novo colaborador precisa de acesso",
status: ticketStatusSchema.enum.NEW,
status: ticketStatusSchema.enum.PENDING,
priority: ticketPrioritySchema.enum.MEDIUM,
channel: ticketChannelSchema.enum.MANUAL,
queue: "Field Services",
@ -141,7 +141,7 @@ const baseTickets = [
reference: 41004,
subject: "Bug no app mobile - upload de foto",
summary: "Upload trava com arquivos acima de 5MB",
status: ticketStatusSchema.enum.ON_HOLD,
status: ticketStatusSchema.enum.PAUSED,
priority: ticketPrioritySchema.enum.HIGH,
channel: ticketChannelSchema.enum.CHAT,
queue: "Laboratório",
@ -234,13 +234,13 @@ const commentsByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["
}
const timelineByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["timeline"]> = {
"ticket-1001": [
{
id: "timeline-1",
type: "STATUS_CHANGED",
payload: { from: "NEW", to: "OPEN" },
createdAt: subHours(new Date(), 5),
},
"ticket-1001": [
{
id: "timeline-1",
type: "STATUS_CHANGED",
payload: { from: "PENDING", to: "AWAITING_ATTENDANCE" },
createdAt: subHours(new Date(), 5),
},
{
id: "timeline-2",
type: "ASSIGNEE_CHANGED",
@ -254,14 +254,14 @@ const timelineByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["
createdAt: subHours(new Date(), 1),
},
],
"ticket-1002": [
{
id: "timeline-4",
type: "STATUS_CHANGED",
payload: { from: "OPEN", to: "PENDING" },
createdAt: subHours(new Date(), 3),
},
],
"ticket-1002": [
{
id: "timeline-4",
type: "STATUS_CHANGED",
payload: { from: "AWAITING_ATTENDANCE", to: "PAUSED" },
createdAt: subHours(new Date(), 3),
},
],
}
export const ticketDetails = tickets.map((ticket) => ({

View file

@ -1,13 +1,12 @@
import { z } from "zod"
export const ticketStatusSchema = z.enum([
"NEW",
"OPEN",
"PENDING",
"ON_HOLD",
"RESOLVED",
"CLOSED",
])
export const ticketStatusSchema = z.enum([
"PENDING",
"AWAITING_ATTENDANCE",
"PAUSED",
"RESOLVED",
"CLOSED",
])
export type TicketStatus = z.infer<typeof ticketStatusSchema>